feat: Update dashboard and identity-gateway infrastructure

- Add Tenants module to Identity Gateway
- Update Dashboard Auth context and components
- Refactor token service and user/role controllers
- Add Quadlet container definitions for dev environment
This commit is contained in:
Tiago Yamamoto 2025-12-31 17:41:04 -03:00
parent 3a236a250a
commit ebb405c4e4
30 changed files with 4641 additions and 76 deletions

View file

@ -0,0 +1,23 @@
[Unit]
Description=Core Dashboard (Ambiente Dev)
After=network-online.target
[Service]
Restart=always
Environment=REGISTRY_AUTH_FILE=/run/user/0/containers/auth.json
[Container]
Image=rg.fr-par.scw.cloud/yumi/dashboard:latest
ContainerName=core-dashboard-dev
Pull=always
EnvironmentFile=/mnt/data/core/dashboard/.env
PublishPort=3005:3000
Network=web_proxy
Label=traefik.enable=true
Label=traefik.http.routers.core-dashboard-dev.rule=Host(`dev.rede5.com.br`)
Label=traefik.http.routers.core-dashboard-dev.entrypoints=websecure
Label=traefik.http.routers.core-dashboard-dev.tls.certresolver=myresolver
Label=traefik.http.services.core-dashboard-dev.loadbalancer.server.port=3000
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,24 @@
[Unit]
Description=Core Identity Gateway (Ambiente Dev)
After=network-online.target postgres.service
[Service]
Restart=always
Environment=REGISTRY_AUTH_FILE=/run/user/0/containers/auth.json
[Container]
Image=rg.fr-par.scw.cloud/yumi/identity-gateway:latest
ContainerName=core-identity-gateway-dev
Pull=always
EnvironmentFile=/mnt/data/core/identity-gateway/.env
Environment=DATABASE_URL=postgres://identity:identity@postgres-main:5432/identity_gateway
PublishPort=4005:4000
Network=web_proxy
Label=traefik.enable=true
Label=traefik.http.routers.core-identity-gateway-dev.rule=Host(`ig-dev.rede5.com.br`)
Label=traefik.http.routers.core-identity-gateway-dev.entrypoints=websecure
Label=traefik.http.routers.core-identity-gateway-dev.tls.certresolver=myresolver
Label=traefik.http.services.core-identity-gateway-dev.loadbalancer.server.port=4000
[Install]
WantedBy=multi-user.target

12
dashboard/.env.example Normal file
View file

@ -0,0 +1,12 @@
# Identity Gateway
VITE_IDENTITY_GATEWAY_URL=https://ig-dev.rede5.com.br
VITE_TENANT_ID=your-tenant-id-here
# Appwrite (legacy - can be removed)
VITE_APPWRITE_ENDPOINT=
VITE_APPWRITE_PROJECT_ID=
VITE_APPWRITE_DATABASE_ID=
VITE_APPWRITE_COLLECTION_SERVERS_ID=
VITE_APPWRITE_COLLECTION_GITHUB_REPOS_ID=
VITE_APPWRITE_COLLECTION_AUDIT_LOGS_ID=
VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID=

View file

@ -10,8 +10,15 @@ RUN npm ci
COPY . .
RUN npm run build
# COPY nginx.conf /etc/nginx/conf.d/default.conf
# Stage 2: Serve with Distroless Node.js
FROM gcr.io/distroless/nodejs20-debian12
EXPOSE 80
WORKDIR /app
CMD ["nginx", "-g", "daemon off;"]
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server.js ./server.js
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["server.js"]

View file

@ -21,7 +21,7 @@ export function TerminalLogs() {
const logTitle = useMemo(() => (open ? 'Ocultar Terminal' : 'Mostrar Terminal'), [open])
useEffect(() => {
if (!appwriteDatabaseId || !appwriteCollectionAuditLogsId) return undefined
if (!appwriteDatabaseId || !appwriteCollectionAuditLogsId || !client) return undefined
const channel = `databases.${appwriteDatabaseId}.collections.${appwriteCollectionAuditLogsId}.documents`
const unsubscribe = client.subscribe(channel, (response) => {

View file

@ -31,22 +31,16 @@ export default function UserDropdown() {
navigate('/login')
}
const getInitials = (name?: string, email?: string) => {
if (name) {
const names = name.split(' ')
return names.length >= 2
? `${names[0][0]}${names[names.length - 1][0]}`.toUpperCase()
: name.substring(0, 2).toUpperCase()
}
if (email) {
return email.substring(0, 2).toUpperCase()
const getInitials = (identifier?: string) => {
if (identifier) {
return identifier.substring(0, 2).toUpperCase()
}
return 'U'
}
const initials = getInitials(user?.name, user?.email)
const displayName = user?.name || user?.email?.split('@')[0] || 'Usuário'
const displayEmail = user?.email || ''
const initials = getInitials(user?.identifier)
const displayName = user?.identifier?.split('@')[0] || 'Usuário'
const displayEmail = user?.identifier || ''
return (
<div className="relative" ref={dropdownRef}>

View file

@ -1,10 +1,9 @@
import { AppwriteException, Models } from 'appwrite'
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { account } from '../lib/appwrite'
import { getCurrentUser, loginUser, logoutUser, User } from '../lib/auth'
type AuthContextValue = {
user: Models.User<Models.Preferences> | null
user: User | null
loading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
@ -14,21 +13,17 @@ type AuthContextValue = {
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null)
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const fetchUser = useCallback(async () => {
try {
setLoading(true)
const current = await account.get()
const current = await getCurrentUser()
setUser(current)
} catch (error) {
const authError = error as AppwriteException
if (authError?.code === 401) {
setUser(null)
} else {
console.error('Erro ao buscar usuário atual', error)
}
console.error('Erro ao buscar usuário atual', error)
setUser(null)
} finally {
setLoading(false)
}
@ -40,14 +35,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const login = useCallback(
async (email: string, password: string) => {
await account.createEmailPasswordSession(email, password)
await loginUser(email, password)
await fetchUser()
},
[fetchUser],
)
const logout = useCallback(async () => {
await account.deleteSession('current')
await logoutUser()
setUser(null)
}, [])

View file

@ -49,7 +49,7 @@ export default function DashboardLayout() {
<div className="mt-auto space-y-2 border-t border-slate-800 pt-3 text-sm text-slate-300">
<p className="flex items-center justify-between text-xs text-slate-500">
<span className="truncate">{user?.email}</span>
<span className="truncate">{user?.identifier}</span>
<Terminal size={14} className="text-cyan-300" />
</p>
</div>

View file

@ -1,22 +1,21 @@
import { Account, AppwriteException, Client, Databases, Functions, Models } from 'appwrite'
const appwriteEndpoint = import.meta.env.VITE_APPWRITE_ENDPOINT
const appwriteProjectId = import.meta.env.VITE_APPWRITE_PROJECT_ID
const appwriteEndpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || ''
const appwriteProjectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
const appwriteDatabaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID
const appwriteCollectionServersId = import.meta.env.VITE_APPWRITE_COLLECTION_SERVERS_ID
const appwriteCollectionGithubReposId = import.meta.env.VITE_APPWRITE_COLLECTION_GITHUB_REPOS_ID
const appwriteCollectionAuditLogsId = import.meta.env.VITE_APPWRITE_COLLECTION_AUDIT_LOGS_ID
const appwriteCollectionCloudflareAccountsId = import.meta.env.VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID
if (!appwriteEndpoint || !appwriteProjectId) {
throw new Error('Defina VITE_APPWRITE_ENDPOINT e VITE_APPWRITE_PROJECT_ID no ambiente do Vite.')
}
// Appwrite is now optional - Identity Gateway is the primary auth provider
const client = appwriteEndpoint && appwriteProjectId
? new Client().setEndpoint(appwriteEndpoint).setProject(appwriteProjectId)
: null
const client = new Client().setEndpoint(appwriteEndpoint).setProject(appwriteProjectId)
const account = new Account(client)
const databases = new Databases(client)
const functions = new Functions(client)
const account = client ? new Account(client) : null
const databases = client ? new Databases(client) : null
const functions = client ? new Functions(client) : null
const isSessionExpired = (error: unknown) => {
if (error instanceof AppwriteException) {
@ -28,10 +27,12 @@ const isSessionExpired = (error: unknown) => {
}
export const loginUser = async (email: string, password: string) => {
if (!account) throw new Error('Appwrite not configured')
return account.createEmailPasswordSession(email, password)
}
export const logoutUser = async () => {
if (!account) return
try {
await account.deleteSession('current')
} catch (error) {
@ -42,6 +43,7 @@ export const logoutUser = async () => {
}
export const getCurrentUser = async (): Promise<Models.User<Models.Preferences> | null> => {
if (!account) return null
try {
return await account.get()
} catch (error) {

142
dashboard/src/lib/auth.ts Normal file
View file

@ -0,0 +1,142 @@
const API_URL = import.meta.env.VITE_IDENTITY_GATEWAY_URL || "https://ig-dev.rede5.com.br";
// Fallback to Rede5 dev tenant ID if env var is missing
const TENANT_ID = import.meta.env.VITE_TENANT_ID || "aff0064a-6797-421f-af0f-832438608a95";
export interface User {
id: string;
identifier: string;
status: string;
roles?: string[];
permissions?: string[];
tenantId?: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
}
let accessToken: string | null = null;
export const getAccessToken = () => accessToken;
export const setAccessToken = (token: string | null) => {
accessToken = token;
};
export const loginUser = async (email: string, password: string): Promise<LoginResponse> => {
const response = await fetch(`${API_URL}/auth/login`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: email,
secret: password,
tenantId: TENANT_ID,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Login failed");
}
const data = await response.json();
accessToken = data.accessToken;
return data;
};
export const logoutUser = async (): Promise<void> => {
await fetch(`${API_URL}/auth/logout`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
},
});
accessToken = null;
};
export const refreshToken = async (): Promise<string | null> => {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
accessToken = null;
return null;
}
const data = await response.json();
accessToken = data.accessToken;
return data.accessToken;
};
export const getCurrentUser = async (): Promise<User | null> => {
if (!accessToken) {
// Try to refresh token first
const refreshed = await refreshToken();
if (!refreshed) {
return null;
}
}
const response = await fetch(`${API_URL}/users/me`, {
method: "GET",
credentials: "include",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
if (response.status === 401) {
// Try refresh and retry
const refreshed = await refreshToken();
if (!refreshed) {
return null;
}
const retryResponse = await fetch(`${API_URL}/users/me`, {
method: "GET",
credentials: "include",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!retryResponse.ok) {
return null;
}
return retryResponse.json();
}
return null;
}
return response.json();
};
// Helper for authenticated API calls
export const authFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
const headers = {
...options.headers,
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
};
let response = await fetch(url, { ...options, credentials: "include", headers });
if (response.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
headers.Authorization = `Bearer ${accessToken}`;
response = await fetch(url, { ...options, credentials: "include", headers });
}
}
return response;
};

View file

@ -11,3 +11,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</AuthProvider>
</React.StrictMode>,
)
// Force rebuild Tue Dec 30 18:43:49 -03 2025

View file

@ -41,7 +41,10 @@ export default function AccountsAdmin() {
const fetchAccounts = async () => {
try {
if (!appwriteDatabaseId) return
if (!appwriteDatabaseId || !databases) {
setLoading(false)
return
}
const response = await databases.listDocuments<Account>(
appwriteDatabaseId,
COLLECTION_ID,
@ -157,8 +160,8 @@ export default function AccountsAdmin() {
{/* Status */}
<span className={`rounded px-2 py-0.5 text-xs ${account.active
? 'bg-emerald-500/20 text-emerald-300'
: 'bg-slate-700/50 text-slate-400'
? 'bg-emerald-500/20 text-emerald-300'
: 'bg-slate-700/50 text-slate-400'
}`}>
{account.active ? 'Ativo' : 'Inativo'}
</span>

View file

@ -40,7 +40,7 @@ export default function Cloudflare() {
useEffect(() => {
const loadCredentials = async () => {
if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId) return
if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId || !databases) return
try {
const response = await databases.listDocuments(appwriteDatabaseId, appwriteCollectionCloudflareAccountsId, [
Query.equal('provider', 'cloudflare'),
@ -54,7 +54,7 @@ export default function Cloudflare() {
}
const loadWorkers = async () => {
if (!appwriteDatabaseId || !appwriteCollectionServersId) return
if (!appwriteDatabaseId || !appwriteCollectionServersId || !databases) return
try {
const servers = await databases.listDocuments<Models.Document & { status?: string }>(
appwriteDatabaseId,
@ -76,7 +76,7 @@ export default function Cloudflare() {
const handleSaveKey = async (event: FormEvent) => {
event.preventDefault()
setError(null)
if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId) {
if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId || !databases) {
setError('Configure VITE_APPWRITE_DATABASE_ID e VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID para salvar a chave de API.')
return
}
@ -103,6 +103,10 @@ export default function Cloudflare() {
setError('Selecione ou cadastre uma credencial Cloudflare antes de consultar o status.')
return
}
if (!functions) {
setError('Appwrite functions not configured.')
return
}
try {
const executionResult = await functions.createExecution(
'check-cloudflare-status',

View file

@ -25,6 +25,12 @@ export default function Github() {
setLoading(true)
setError(null)
if (!functions) {
setError('Appwrite functions not configured.')
setLoading(false)
return
}
try {
const executionResult = await functions.createExecution(
'sync-github',

View file

@ -14,6 +14,12 @@ export default function Hello() {
setError(null)
setMessage(null)
if (!functions) {
setError('Appwrite functions not configured.')
setLoading(false)
return
}
try {
const executionResult = await functions.createExecution(
'hello-world',

View file

@ -38,6 +38,10 @@ export default function Home() {
const fetchMetrics = async () => {
try {
if (!databases) {
setError('Appwrite not configured. Configure VITE_APPWRITE_* variables to enable metrics.')
return
}
const [repos, servers, deployments] = await Promise.all([
databases.listDocuments<Repo>(appwriteDatabaseId!, appwriteCollectionGithubReposId!, [Query.limit(1)]),
databases.listDocuments<Server>(appwriteDatabaseId!, appwriteCollectionServersId!, [Query.limit(200)]),

View file

@ -4,23 +4,22 @@ import { useAuth } from '../contexts/Auth'
export default function Profile() {
const { user } = useAuth()
const getInitials = (name?: string, email?: string) => {
if (name) {
const names = name.split(' ')
return names.length >= 2
? `${names[0][0]}${names[names.length - 1][0]}`.toUpperCase()
: name.substring(0, 2).toUpperCase()
}
if (email) {
return email.substring(0, 2).toUpperCase()
const getInitials = (identifier?: string) => {
if (identifier) {
// If it's an email, use first 2 chars before @
const atIndex = identifier.indexOf('@')
if (atIndex > 0) {
return identifier.substring(0, 2).toUpperCase()
}
return identifier.substring(0, 2).toUpperCase()
}
return 'U'
}
const initials = getInitials(user?.name, user?.email)
const displayName = user?.name || 'Usuário'
const displayEmail = user?.email || 'email@exemplo.com'
const createdAt = user?.$createdAt ? new Date(user.$createdAt).toLocaleDateString('pt-BR') : 'N/A'
const initials = getInitials(user?.identifier)
const displayName = user?.identifier?.split('@')[0] || 'Usuário'
const displayEmail = user?.identifier || 'email@exemplo.com'
const createdAt = 'N/A' // Identity Gateway doesn't return createdAt in /users/me
return (
<div className="space-y-6">
@ -72,7 +71,7 @@ export default function Profile() {
<User size={18} className="text-cyan-400" />
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-500">User ID</p>
<p className="truncate font-mono text-xs text-slate-200">{user?.$id || 'N/A'}</p>
<p className="truncate font-mono text-xs text-slate-200">{user?.id || 'N/A'}</p>
</div>
</div>

3710
identity-gateway/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,21 +11,24 @@
"lint": "eslint . --ext .ts"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/swagger": "^9.6.1",
"@fastify/swagger-ui": "^5.2.3",
"ajv-formats": "^3.0.1",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"fastify": "^4.28.1",
"@fastify/cookie": "^9.3.1",
"fastify": "^5.6.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^8.19.0"
"pino": "^8.19.0",
"pino-pretty": "^11.2.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"eslint": "^9.6.0",
"pino-pretty": "^11.2.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.3"
}

View file

@ -10,7 +10,7 @@ export interface TokenPair {
export class TokenService {
signAccessToken(payload: TenantContext) {
return jwt.sign(payload, env.jwtAccessSecret, {
expiresIn: env.jwtAccessTtl,
expiresIn: env.jwtAccessTtl as any,
});
}
@ -22,7 +22,7 @@ export class TokenService {
},
env.jwtRefreshSecret,
{
expiresIn: env.jwtRefreshTtl,
expiresIn: env.jwtRefreshTtl as any,
}
);
}

View file

@ -0,0 +1,85 @@
import { db } from "./db";
import { hashSecret } from "./crypto";
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || "admin@rede5.com.br";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "admin123";
const DEFAULT_TENANT_NAME = "Rede5";
export const seed = async () => {
console.log("[Seed] Checking if seed is needed...");
// Check if default tenant exists
const tenantResult = await db.query<{ id: string }>(
"SELECT id FROM tenants WHERE name = $1",
[DEFAULT_TENANT_NAME]
);
let tenantId: string;
if (tenantResult.rowCount === 0) {
console.log("[Seed] Creating default tenant...");
const newTenant = await db.query<{ id: string }>(
"INSERT INTO tenants (name) VALUES ($1) RETURNING id",
[DEFAULT_TENANT_NAME]
);
tenantId = newTenant.rows[0].id;
} else {
tenantId = tenantResult.rows[0].id;
console.log("[Seed] Default tenant already exists.");
}
// Check if admin user exists
const userResult = await db.query<{ id: string }>(
"SELECT id FROM users WHERE identifier = $1",
[ADMIN_EMAIL]
);
let userId: string;
if (userResult.rowCount === 0) {
console.log("[Seed] Creating admin user...");
const passwordHash = await hashSecret(ADMIN_PASSWORD);
const newUser = await db.query<{ id: string }>(
"INSERT INTO users (identifier, password_hash, status) VALUES ($1, $2, 'active') RETURNING id",
[ADMIN_EMAIL, passwordHash]
);
userId = newUser.rows[0].id;
} else {
userId = userResult.rows[0].id;
console.log("[Seed] Admin user already exists.");
}
// Check if admin role exists
const roleResult = await db.query<{ id: string }>(
"SELECT id FROM roles WHERE name = $1",
["admin"]
);
let roleId: string;
if (roleResult.rowCount === 0) {
console.log("[Seed] Creating admin role...");
const newRole = await db.query<{ id: string }>(
"INSERT INTO roles (name, description) VALUES ('admin', 'Administrator with full access') RETURNING id",
[]
);
roleId = newRole.rows[0].id;
} else {
roleId = roleResult.rows[0].id;
}
// Link user to tenant
await db.query(
"INSERT INTO user_tenants (user_id, tenant_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[userId, tenantId]
);
// Assign admin role to user in tenant
await db.query(
"INSERT INTO user_roles (user_id, tenant_id, role_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[userId, tenantId, roleId]
);
console.log("[Seed] Seed completed successfully!");
console.log(`[Seed] Admin: ${ADMIN_EMAIL} | Tenant: ${DEFAULT_TENANT_NAME}`);
};

View file

@ -1,7 +1,11 @@
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
import { assertEnv, env } from "./lib/env";
import { logger } from "./lib/logger";
import { seed } from "./lib/seed";
import { TokenService } from "./core/token.service";
import { UserService } from "./modules/users/user.service";
import { SessionService } from "./modules/sessions/session.service";
@ -10,16 +14,100 @@ import { LocalProvider } from "./modules/providers/local.provider";
import { AuthProvider } from "./modules/providers/provider.interface";
import { registerAuthRoutes } from "./modules/auth/auth.controller";
import { registerUserRoutes } from "./modules/users/user.controller";
import { TenantService } from "./modules/tenants/tenant.service";
import { registerTenantRoutes } from "./modules/tenants/tenant.controller";
import { RoleService } from "./modules/roles/role.service";
import { registerRoleRoutes } from "./modules/roles/role.controller";
const VERSION = "0.1.0";
const bootstrap = async () => {
assertEnv();
const app = Fastify({ logger });
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
...(process.env.NODE_ENV !== "production" && {
transport: {
target: "pino-pretty",
options: { colorize: true },
},
}),
},
});
// CORS - Allow dashboard to access identity gateway
await app.register(cors, {
origin: [
"https://dev.rede5.com.br",
"https://dashboard.rede5.com.br",
"http://localhost:5173",
"http://localhost:3000",
],
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
});
// Swagger
await app.register(swagger, {
openapi: {
info: {
title: "Identity Gateway API",
description: "Internal identity gateway for multi-service SaaS platforms",
version: VERSION,
},
servers: [{ url: `https://ig-dev.rede5.com.br` }],
},
});
await app.register(swaggerUi, {
routePrefix: "/docs",
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
});
app.register(cookie);
// AJV Formats for UUID validation
const Ajv = require("ajv");
const ajv = new Ajv({
removeAdditional: true,
useDefaults: true,
coerceTypes: true,
allErrors: true,
});
require("ajv-formats")(ajv);
app.setValidatorCompiler(({ schema }) => {
return ajv.compile(schema);
});
// Root endpoint
app.get("/", async (request) => {
const ip = (request.headers["x-forwarded-for"] as string)?.split(",")[0] ||
request.headers["x-real-ip"] ||
request.ip;
return {
message: "🔐 Identity Gateway is running!",
version: VERSION,
docs: "/docs",
health: "/health",
ip,
};
});
// Health endpoint
app.get("/health", async () => {
return { status: "ok", timestamp: new Date().toISOString() };
});
const tokenService = new TokenService();
const userService = new UserService();
const sessionService = new SessionService();
const tenantService = new TenantService();
const roleService = new RoleService();
const providers = new Map<string, AuthProvider>();
providers.set("local", new LocalProvider(userService));
@ -31,8 +119,13 @@ const bootstrap = async () => {
providers
);
registerAuthRoutes(app, authService);
registerUserRoutes(app, userService, tokenService);
registerAuthRoutes(app as any, authService);
registerUserRoutes(app as any, userService, tokenService);
registerTenantRoutes(app as any, tenantService, tokenService);
registerRoleRoutes(app as any, roleService, tokenService);
// Run seed on startup
await seed();
await app.listen({ port: env.port, host: "0.0.0.0" });
};

View file

@ -3,7 +3,20 @@ import { AuthService } from "./auth.service";
import { env } from "../../lib/env";
export const registerAuthRoutes = (app: FastifyInstance, authService: AuthService) => {
app.post("/auth/login", async (request, reply) => {
app.post("/auth/login", {
schema: {
body: {
type: "object",
required: ["identifier", "secret", "tenantId"],
properties: {
provider: { type: "string" },
identifier: { type: "string" },
secret: { type: "string" },
tenantId: { type: "string", format: "uuid" },
},
},
},
}, async (request, reply) => {
const body = request.body as {
provider?: string;
identifier: string;

View file

@ -0,0 +1,94 @@
import { FastifyInstance } from "fastify";
import { RoleService } from "./role.service";
import { authGuard } from "../../core/auth.guard";
import { TokenService } from "../../core/token.service";
export const registerRoleRoutes = (
app: FastifyInstance,
roleService: RoleService,
tokenService: TokenService
) => {
// List all roles
app.get(
"/roles",
{ preHandler: authGuard(tokenService) },
async () => {
return roleService.listRoles();
}
);
// Get role by ID
app.get(
"/roles/:id",
{ preHandler: authGuard(tokenService) },
async (request) => {
const { id } = request.params as { id: string };
return roleService.findById(id);
}
);
// Create role
app.post(
"/roles",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { name, description } = request.body as { name: string; description?: string };
if (!name) {
reply.code(400).send({ message: "Name is required" });
return;
}
const role = await roleService.createRole(name, description);
reply.code(201).send(role);
}
);
// Update role
app.put(
"/roles/:id",
{ preHandler: authGuard(tokenService) },
async (request) => {
const { id } = request.params as { id: string };
const { name, description } = request.body as { name: string; description?: string };
return roleService.updateRole(id, name, description);
}
);
// Delete role
app.delete(
"/roles/:id",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { id } = request.params as { id: string };
await roleService.deleteRole(id);
reply.code(204).send();
}
);
// Assign role to user
app.post(
"/roles/:roleId/users/:userId",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { roleId, userId } = request.params as { roleId: string; userId: string };
const { tenantId } = request.body as { tenantId: string };
if (!tenantId) {
reply.code(400).send({ message: "tenantId is required" });
return;
}
await roleService.assignRoleToUser(userId, tenantId, roleId);
reply.code(201).send({ success: true });
}
);
// Remove role from user
app.delete(
"/roles/:roleId/users/:userId",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { roleId, userId } = request.params as { roleId: string; userId: string };
const { tenantId } = request.body as { tenantId: string };
await roleService.removeRoleFromUser(userId, tenantId, roleId);
reply.code(204).send();
}
);
};

View file

@ -1,19 +1,75 @@
import { db } from "../../lib/db";
import { RoleEntity } from "./role.entity";
export interface RoleEntity {
id: string;
name: string;
description: string | null;
}
export class RoleService {
async createRole(name: string, description?: string) {
async createRole(name: string, description?: string): Promise<RoleEntity> {
const result = await db.query<RoleEntity>(
"INSERT INTO roles (name, description) VALUES ($1, $2) RETURNING id, name, description",
[name, description ?? null]
[name, description || null]
);
return result.rows[0];
}
async listRoles() {
async findById(id: string): Promise<RoleEntity> {
const result = await db.query<RoleEntity>(
"SELECT id, name, description FROM roles WHERE id = $1",
[id]
);
if (result.rowCount === 0) {
throw new Error("Role not found");
}
return result.rows[0];
}
async findByName(name: string): Promise<RoleEntity | null> {
const result = await db.query<RoleEntity>(
"SELECT id, name, description FROM roles WHERE name = $1",
[name]
);
return result.rows[0] || null;
}
async listRoles(): Promise<RoleEntity[]> {
const result = await db.query<RoleEntity>(
"SELECT id, name, description FROM roles ORDER BY name"
);
return result.rows;
}
async updateRole(id: string, name: string, description?: string): Promise<RoleEntity> {
const result = await db.query<RoleEntity>(
"UPDATE roles SET name = $2, description = $3 WHERE id = $1 RETURNING id, name, description",
[id, name, description || null]
);
if (result.rowCount === 0) {
throw new Error("Role not found");
}
return result.rows[0];
}
async deleteRole(id: string): Promise<void> {
const result = await db.query("DELETE FROM roles WHERE id = $1", [id]);
if (result.rowCount === 0) {
throw new Error("Role not found");
}
}
async assignRoleToUser(userId: string, tenantId: string, roleId: string): Promise<void> {
await db.query(
"INSERT INTO user_roles (user_id, tenant_id, role_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[userId, tenantId, roleId]
);
}
async removeRoleFromUser(userId: string, tenantId: string, roleId: string): Promise<void> {
await db.query(
"DELETE FROM user_roles WHERE user_id = $1 AND tenant_id = $2 AND role_id = $3",
[userId, tenantId, roleId]
);
}
}

View file

@ -0,0 +1,66 @@
import { FastifyInstance } from "fastify";
import { TenantService } from "./tenant.service";
import { authGuard } from "../../core/auth.guard";
import { TokenService } from "../../core/token.service";
export const registerTenantRoutes = (
app: FastifyInstance,
tenantService: TenantService,
tokenService: TokenService
) => {
// List all tenants
app.get(
"/tenants",
{ preHandler: authGuard(tokenService) },
async () => {
return tenantService.listTenants();
}
);
// Get tenant by ID
app.get(
"/tenants/:id",
{ preHandler: authGuard(tokenService) },
async (request) => {
const { id } = request.params as { id: string };
return tenantService.findById(id);
}
);
// Create tenant
app.post(
"/tenants",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { name } = request.body as { name: string };
if (!name) {
reply.code(400).send({ message: "Name is required" });
return;
}
const tenant = await tenantService.createTenant(name);
reply.code(201).send(tenant);
}
);
// Update tenant
app.put(
"/tenants/:id",
{ preHandler: authGuard(tokenService) },
async (request) => {
const { id } = request.params as { id: string };
const { name } = request.body as { name: string };
return tenantService.updateTenant(id, name);
}
);
// Delete tenant
app.delete(
"/tenants/:id",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { id } = request.params as { id: string };
await tenantService.deleteTenant(id);
reply.code(204).send();
}
);
};

View file

@ -0,0 +1,61 @@
import { db } from "../../lib/db";
export interface TenantEntity {
id: string;
name: string;
createdAt: Date;
}
export class TenantService {
async createTenant(name: string): Promise<TenantEntity> {
const result = await db.query<TenantEntity>(
'INSERT INTO tenants (name) VALUES ($1) RETURNING id, name, created_at as "createdAt"',
[name]
);
return result.rows[0];
}
async findById(id: string): Promise<TenantEntity> {
const result = await db.query<TenantEntity>(
'SELECT id, name, created_at as "createdAt" FROM tenants WHERE id = $1',
[id]
);
if (result.rowCount === 0) {
throw new Error("Tenant not found");
}
return result.rows[0];
}
async findByName(name: string): Promise<TenantEntity | null> {
const result = await db.query<TenantEntity>(
'SELECT id, name, created_at as "createdAt" FROM tenants WHERE name = $1',
[name]
);
return result.rows[0] || null;
}
async listTenants(): Promise<TenantEntity[]> {
const result = await db.query<TenantEntity>(
'SELECT id, name, created_at as "createdAt" FROM tenants ORDER BY created_at DESC'
);
return result.rows;
}
async updateTenant(id: string, name: string): Promise<TenantEntity> {
const result = await db.query<TenantEntity>(
'UPDATE tenants SET name = $2 WHERE id = $1 RETURNING id, name, created_at as "createdAt"',
[id, name]
);
if (result.rowCount === 0) {
throw new Error("Tenant not found");
}
return result.rows[0];
}
async deleteTenant(id: string): Promise<void> {
const result = await db.query("DELETE FROM tenants WHERE id = $1", [id]);
if (result.rowCount === 0) {
throw new Error("Tenant not found");
}
}
}

View file

@ -9,6 +9,7 @@ export const registerUserRoutes = (
userService: UserService,
tokenService: TokenService
) => {
// Get current user
app.get(
"/users/me",
{ preHandler: authGuard(tokenService) },
@ -22,4 +23,103 @@ export const registerUserRoutes = (
};
}
);
// List all users
app.get(
"/users",
{ preHandler: authGuard(tokenService) },
async () => {
return userService.listUsers();
}
);
// Get user by ID
app.get(
"/users/:id",
{ preHandler: authGuard(tokenService) },
async (request) => {
const { id } = request.params as { id: string };
const user = await userService.findById(id);
return {
id: user.id,
identifier: user.identifier,
status: user.status,
createdAt: user.createdAt,
};
}
);
// Create user
app.post(
"/users",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { identifier, password, tenantId } = request.body as {
identifier: string;
password: string;
tenantId?: string;
};
if (!identifier || !password) {
reply.code(400).send({ message: "identifier and password are required" });
return;
}
const user = await userService.createUser(identifier, password);
if (tenantId) {
await userService.addUserToTenant(user.id, tenantId);
}
reply.code(201).send({
id: user.id,
identifier: user.identifier,
status: user.status,
});
}
);
// Update user
app.put(
"/users/:id",
{ preHandler: authGuard(tokenService) },
async (request) => {
const { id } = request.params as { id: string };
const { identifier, status, password } = request.body as {
identifier?: string;
status?: string;
password?: string;
};
return userService.updateUser(id, { identifier, status, password });
}
);
// Delete user
app.delete(
"/users/:id",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { id } = request.params as { id: string };
await userService.deleteUser(id);
reply.code(204).send();
}
);
// Add user to tenant
app.post(
"/users/:userId/tenants/:tenantId",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { userId, tenantId } = request.params as { userId: string; tenantId: string };
await userService.addUserToTenant(userId, tenantId);
reply.code(201).send({ success: true });
}
);
// Remove user from tenant
app.delete(
"/users/:userId/tenants/:tenantId",
{ preHandler: authGuard(tokenService) },
async (request, reply) => {
const { userId, tenantId } = request.params as { userId: string; tenantId: string };
await userService.removeUserFromTenant(userId, tenantId);
reply.code(204).send();
}
);
};

View file

@ -34,6 +34,51 @@ export class UserService {
return result.rows[0];
}
async listUsers() {
const result = await db.query<UserEntity>(
"SELECT id, identifier, status, created_at as \"createdAt\" FROM users ORDER BY created_at DESC"
);
return result.rows;
}
async updateUser(userId: string, updates: { identifier?: string; status?: string; password?: string }) {
const user = await this.findById(userId);
const newIdentifier = updates.identifier ?? user.identifier;
const newStatus = updates.status ?? user.status;
let newPasswordHash = user.passwordHash;
if (updates.password) {
newPasswordHash = await hashSecret(updates.password);
}
const result = await db.query<UserEntity>(
"UPDATE users SET identifier = $2, status = $3, password_hash = $4 WHERE id = $1 RETURNING id, identifier, status, created_at as \"createdAt\"",
[userId, newIdentifier, newStatus, newPasswordHash]
);
return result.rows[0];
}
async deleteUser(userId: string) {
const result = await db.query("DELETE FROM users WHERE id = $1", [userId]);
if (result.rowCount === 0) {
throw new Error("User not found");
}
}
async addUserToTenant(userId: string, tenantId: string) {
await db.query(
"INSERT INTO user_tenants (user_id, tenant_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[userId, tenantId]
);
}
async removeUserFromTenant(userId: string, tenantId: string) {
await db.query(
"DELETE FROM user_tenants WHERE user_id = $1 AND tenant_id = $2",
[userId, tenantId]
);
}
async verifyCredentials(identifier: string, password: string) {
const user = await this.findByIdentifier(identifier);
if (user.status !== "active") {

17
quick_push.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
set -e
REGISTRY="rg.fr-par.scw.cloud/yumi"
# Dashboard
echo "🚀 Building dashboard..."
podman build -t "$REGISTRY/dashboard:latest" ./dashboard
echo "🚀 Pushing dashboard..."
podman push "$REGISTRY/dashboard:latest"
# Identity Gateway
echo "🚀 Building identity-gateway..."
podman build -t "$REGISTRY/identity-gateway:latest" ./identity-gateway
echo "🚀 Pushing identity-gateway..."
podman push "$REGISTRY/identity-gateway:latest"
echo "✅ Quick push done!"