mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
refactor(auth): streamline onboarding process and enhance registration flow
- Removed the onboarding module and its associated components to simplify the application structure. - Introduced a new welcome module with components for site schema and registration wizard. - Updated routing to direct users to the new welcome page for tenant registration. - Enhanced the registration process by integrating social authentication options and improving user experience. - Refactored related hooks and utilities to support the new onboarding flow. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG NODE_VERSION=22.11.0
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS builder
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY be/apps/core/package.json be/apps/core/package.json
|
||||
COPY be/apps/dashboard/package.json be/apps/dashboard/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
|
||||
RUN pnpm fetch --filter core...
|
||||
RUN pnpm fetch --filter '@afilmory/web...'
|
||||
RUN pnpm fetch --filter '@afilmory/dashboard...'
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --filter core... --filter '@afilmory/web...' --filter '@afilmory/dashboard...' --frozen-lockfile
|
||||
RUN pnpm --filter core build:web
|
||||
RUN pnpm --filter @afilmory/dashboard build
|
||||
RUN pnpm --filter core build
|
||||
RUN mkdir -p be/apps/core/dist/static/web && cp -r apps/web/dist/. be/apps/core/dist/static/web/
|
||||
RUN mkdir -p be/apps/core/dist/static/dashboard && cp -r be/apps/dashboard/dist/. be/apps/core/dist/static/dashboard/
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS runner
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk install --no-cache perl
|
||||
COPY --from=builder /workspace/be/apps/core/dist ./dist
|
||||
COPY --from=builder /workspace/be/apps/core/drizzle ./drizzle
|
||||
|
||||
RUN if [ -f dist/package.json ]; then \
|
||||
cd dist && \
|
||||
npm install --omit=dev --no-audit --no-fund; \
|
||||
fi
|
||||
|
||||
EXPOSE 1841
|
||||
|
||||
CMD ["node", "./dist/main.js"]
|
||||
@@ -12,9 +12,7 @@
|
||||
"db:migrate": "pnpm -C ../../packages/db db:migrate",
|
||||
"db:studio": "pnpm -C ../../packages/db db:studio",
|
||||
"dev": "nodemon",
|
||||
"dev:reset-superadmin-password": "vite-node src/index.ts --reset-superadmin-password",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"dev:reset-superadmin-password": "vite-node src/index.ts --reset-superadmin-password"
|
||||
},
|
||||
"dependencies": {
|
||||
"@afilmory/be-utils": "workspace:*",
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import type { HonoHttpApplication } from '@afilmory/framework'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createConfiguredApp as createAppFactory } from '../app.factory'
|
||||
|
||||
const BASE_URL = 'http://localhost'
|
||||
|
||||
function buildRequest(path: string, init?: RequestInit) {
|
||||
return new Request(`${BASE_URL}${path}`, init)
|
||||
}
|
||||
|
||||
function authorizedHeaders() {
|
||||
return {
|
||||
'x-api-key': process.env.API_KEY ?? 'secret-key',
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe('HonoHttpApplication integration', () => {
|
||||
let app: HonoHttpApplication
|
||||
let fetcher: (request: Request) => Promise<Response>
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createAppFactory()
|
||||
fetcher = (request: Request) => Promise.resolve(app.getInstance().fetch(request))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close('tests')
|
||||
})
|
||||
|
||||
const json = async (response: Response) => ({
|
||||
status: response.status,
|
||||
data: await response.json(),
|
||||
})
|
||||
|
||||
it('responds to root route without guard', async () => {
|
||||
const response = await fetcher(
|
||||
buildRequest('/api/app?echo=test-suite', {
|
||||
method: 'GET',
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
message: 'Hello from HonoHttpApplication',
|
||||
echo: 'test-suite',
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces guards when API key missing', async () => {
|
||||
const response = await fetcher(buildRequest('/api/app/profiles/5'))
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(401)
|
||||
expect(body.data).toMatchObject({ message: 'Invalid API key' })
|
||||
})
|
||||
|
||||
it('resolves params, query, and pipes when authorized', async () => {
|
||||
const response = await fetcher(
|
||||
buildRequest('/api/app/profiles/7?verbose=true', {
|
||||
headers: authorizedHeaders(),
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
id: 7,
|
||||
username: 'user-7',
|
||||
role: 'member',
|
||||
})
|
||||
expect(body.data.verbose).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns validation error on malformed JSON payload', async () => {
|
||||
const response = await fetcher(
|
||||
buildRequest('/api/app/messages/1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authorizedHeaders(),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: '{ invalid json',
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(400)
|
||||
expect(body.data).toMatchObject({ message: 'Invalid JSON payload' })
|
||||
})
|
||||
|
||||
it('processes body payload with validation and pipes', async () => {
|
||||
const response = await fetcher(
|
||||
buildRequest('/api/app/messages/9', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authorizedHeaders(),
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'vitest-request',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: 'unit test',
|
||||
tags: ['vitest'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
requestId: 'vitest-request',
|
||||
data: {
|
||||
id: 9,
|
||||
message: 'unit test',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('enqueues and processes redis-backed queue jobs', async () => {
|
||||
const enqueue = await fetcher(
|
||||
buildRequest('/api/queue/jobs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient: 'queue-test@example.com',
|
||||
message: 'integration job',
|
||||
attemptsBeforeSuccess: 2,
|
||||
metadata: {
|
||||
source: 'vitest',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const enqueueBody = await json(enqueue)
|
||||
expect(enqueueBody.status).toBe(202)
|
||||
const jobId = enqueueBody.data.jobId as string
|
||||
expect(typeof jobId).toBe('string')
|
||||
|
||||
let jobState: Awaited<ReturnType<typeof json>> | undefined
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
await sleep(100)
|
||||
const statusResponse = await fetcher(buildRequest(`/api/queue/jobs/${jobId}`, { method: 'GET' }))
|
||||
if (statusResponse.status !== 200) {
|
||||
continue
|
||||
}
|
||||
|
||||
jobState = await json(statusResponse)
|
||||
if (['completed', 'failed'].includes(jobState.data.status)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(jobState).toBeDefined()
|
||||
expect(jobState?.status).toBe(200)
|
||||
expect(jobState?.data.status).toBe('completed')
|
||||
expect(jobState?.data.attempts).toBeGreaterThan(1)
|
||||
expect(jobState?.data.result?.deliveredAt).toBeTypeOf('string')
|
||||
|
||||
const listResponse = await fetcher(buildRequest('/api/queue/jobs', { method: 'GET' }))
|
||||
const listBody = await json(listResponse)
|
||||
expect(listBody.status).toBe(200)
|
||||
expect(Array.isArray(listBody.data)).toBe(true)
|
||||
expect(listBody.data.some((item: { id: string }) => item.id === jobId)).toBe(true)
|
||||
|
||||
const statsResponse = await fetcher(buildRequest('/api/queue/stats', { method: 'GET' }))
|
||||
const statsBody = await json(statsResponse)
|
||||
expect(statsBody.status).toBe(200)
|
||||
expect(statsBody.data.trackedJobs).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const missingResponse = await fetcher(buildRequest('/api/queue/jobs/non-existent', { method: 'GET' }))
|
||||
expect(missingResponse.status).toBe(404)
|
||||
})
|
||||
|
||||
it('validates body with zod pipe and reports schema errors', async () => {
|
||||
const response = await fetcher(
|
||||
buildRequest('/api/app/messages/10', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authorizedHeaders(),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ tags: [] }),
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(422)
|
||||
expect(body.data).toMatchObject({
|
||||
statusCode: 422,
|
||||
message: 'Validation failed',
|
||||
errors: {
|
||||
message: ['Message is required'],
|
||||
},
|
||||
meta: {
|
||||
target: 'CreateMessageDto',
|
||||
paramType: 'body',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes HttpContext through async_hooks store', async () => {
|
||||
const response = await fetcher(
|
||||
buildRequest('/api/app/context-check', {
|
||||
method: 'GET',
|
||||
headers: authorizedHeaders(),
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
same: true,
|
||||
path: '/api/app/context-check',
|
||||
})
|
||||
})
|
||||
|
||||
it('delegates unhandled errors to the exception filter', async () => {
|
||||
const response = await fetcher(
|
||||
buildRequest('/api/app/error', {
|
||||
method: 'GET',
|
||||
headers: authorizedHeaders(),
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await json(response)
|
||||
expect(body.status).toBe(500)
|
||||
expect(body.data).toMatchObject({
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
})
|
||||
})
|
||||
})
|
||||
15
be/apps/core/src/context/http-context.values.ts
Normal file
15
be/apps/core/src/context/http-context.values.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Session } from 'better-auth'
|
||||
|
||||
import type { AuthSession } from '../modules/auth/auth.provider'
|
||||
import type { TenantContext } from '../modules/tenant/tenant.types'
|
||||
|
||||
export interface HttpContextAuth {
|
||||
user?: AuthSession['user']
|
||||
session?: Session
|
||||
}
|
||||
declare module '@afilmory/framework' {
|
||||
interface HttpContextValues {
|
||||
tenant?: TenantContext
|
||||
auth?: HttpContextAuth
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,24 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import type { CanActivate, ExecutionContext } from '@afilmory/framework'
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import type { Session } from 'better-auth'
|
||||
import type { HttpContextAuth } from 'core/context/http-context.values'
|
||||
import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { getTenantContext } from 'core/modules/tenant/tenant.context'
|
||||
import { TenantContextResolver } from 'core/modules/tenant/tenant-context-resolver.service'
|
||||
import type { TenantContext } from 'core/modules/tenant/tenant.types'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { shouldSkipTenant } from '../decorators/skip-tenant.decorator'
|
||||
import { logger } from '../helpers/logger.helper'
|
||||
import type { AuthSession } from '../modules/auth/auth.provider'
|
||||
import { AuthProvider } from '../modules/auth/auth.provider'
|
||||
import { getAllowedRoleMask, roleNameToBit } from './roles.decorator'
|
||||
|
||||
declare module '@afilmory/framework' {
|
||||
interface HttpContextValues {
|
||||
auth?: {
|
||||
user?: AuthSession['user']
|
||||
session?: Session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly log = logger.extend('AuthGuard')
|
||||
|
||||
constructor(
|
||||
private readonly authProvider: AuthProvider,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly tenantContextResolver: TenantContextResolver,
|
||||
) {}
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const store = context.getContext()
|
||||
@@ -40,121 +26,129 @@ export class AuthGuard implements CanActivate {
|
||||
const { method, path } = hono.req
|
||||
const handler = context.getHandler()
|
||||
const targetClass = context.getClass()
|
||||
const skipTenant = shouldSkipTenant(handler) || shouldSkipTenant(targetClass)
|
||||
const authContext = HttpContext.getValue('auth')
|
||||
|
||||
if (shouldSkipTenant(handler) || shouldSkipTenant(targetClass)) {
|
||||
if (skipTenant) {
|
||||
this.log.verbose(`Skip guard and tenant resolution for ${method} ${path}`)
|
||||
return true
|
||||
}
|
||||
|
||||
this.log.verbose(`Evaluating guard for ${method} ${path}`)
|
||||
|
||||
let tenantContext = getTenantContext()
|
||||
const tenantContext = this.requireTenantContext(method, path)
|
||||
|
||||
await this.enforceTenantOwnership(authContext, tenantContext, method, path)
|
||||
this.enforceRoleRequirements(handler, authContext, method, path)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private requireTenantContext(method: string, path: string) {
|
||||
const tenantContext = getTenantContext()
|
||||
if (!tenantContext) {
|
||||
const resolvedTenant = await this.tenantContextResolver.resolve(hono, {
|
||||
setResponseHeaders: false,
|
||||
})
|
||||
if (resolvedTenant) {
|
||||
HttpContext.setValue('tenant', resolvedTenant)
|
||||
tenantContext = resolvedTenant
|
||||
this.log.verbose(
|
||||
`Resolved tenant context slug=${resolvedTenant.tenant.slug ?? 'n/a'} id=${resolvedTenant.tenant.id} for ${method} ${path}`,
|
||||
)
|
||||
} else {
|
||||
this.log.verbose(`Tenant context not resolved for ${method} ${path}`)
|
||||
tenantContext = undefined
|
||||
}
|
||||
this.log.warn(`Tenant context not resolved for ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
}
|
||||
return tenantContext
|
||||
}
|
||||
|
||||
private async enforceTenantOwnership(
|
||||
authContext: HttpContextAuth | undefined,
|
||||
tenantContext: TenantContext,
|
||||
method: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
if (!authContext?.user || !authContext.session) {
|
||||
return
|
||||
}
|
||||
|
||||
const { headers } = hono.req.raw
|
||||
const authSession = { user: authContext.user, session: authContext.session } as AuthSession
|
||||
const roleName = (authSession.user as { role?: string }).role as
|
||||
| 'user'
|
||||
| 'admin'
|
||||
| 'superadmin'
|
||||
| 'guest'
|
||||
| undefined
|
||||
const isSuperAdmin = roleName === 'superadmin'
|
||||
|
||||
const globalAuth = await this.authProvider.getAuth()
|
||||
const authSession: AuthSession | null = await globalAuth.api.getSession({ headers })
|
||||
if (!isSuperAdmin) {
|
||||
const tenantId = await this.resolveTenantIdForSession(authSession, method, path)
|
||||
|
||||
if (authSession) {
|
||||
this.log.verbose(`Session detected for user ${(authSession.user as { id?: string }).id ?? 'unknown'}`)
|
||||
} else {
|
||||
this.log.verbose('No session context available (no tenant resolved and no active session)')
|
||||
}
|
||||
|
||||
if (authSession) {
|
||||
HttpContext.assign({
|
||||
auth: {
|
||||
user: authSession.user,
|
||||
session: authSession.session,
|
||||
},
|
||||
})
|
||||
const userRoleValue = (authSession.user as { role?: string }).role
|
||||
const roleName = userRoleValue as 'user' | 'admin' | 'superadmin' | 'guest' | undefined
|
||||
const isSuperAdmin = roleName === 'superadmin'
|
||||
let sessionTenantId = (authSession.user as { tenantId?: string | null }).tenantId ?? null
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
if (!sessionTenantId) {
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
.select({ tenantId: authUsers.tenantId })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, authSession.user.id))
|
||||
.limit(1)
|
||||
sessionTenantId = record?.tenantId ?? ''
|
||||
}
|
||||
|
||||
if (!sessionTenantId) {
|
||||
this.log.warn(
|
||||
`Denied access: session ${(authSession.user as { id?: string }).id ?? 'unknown'} missing tenant id for ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
}
|
||||
|
||||
if (!tenantContext) {
|
||||
this.log.warn(
|
||||
`Denied access: tenant context missing while session tenant=${sessionTenantId} accessing ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
}
|
||||
if (sessionTenantId !== tenantContext.tenant.id) {
|
||||
this.log.warn(
|
||||
`Denied access: session tenant=${sessionTenantId} does not match context tenant=${tenantContext.tenant.id} for ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantContext) {
|
||||
await applyTenantIsolationContext({
|
||||
tenantId: tenantContext.tenant.id,
|
||||
isSuperAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
if (isSuperAdmin) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Role verification if decorator is present
|
||||
const requiredMask = getAllowedRoleMask(handler)
|
||||
if (requiredMask > 0) {
|
||||
if (!authSession) {
|
||||
this.log.warn(`Denied access: missing session for protected resource ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const userRoleName = (authSession.user as { role?: string }).role as
|
||||
| 'user'
|
||||
| 'admin'
|
||||
| 'superadmin'
|
||||
| 'guest'
|
||||
| undefined
|
||||
const userMask = userRoleName ? roleNameToBit(userRoleName) : 0
|
||||
const hasRole = (requiredMask & userMask) !== 0
|
||||
if (!hasRole) {
|
||||
if (tenantId !== tenantContext.tenant.id) {
|
||||
this.log.warn(
|
||||
`Denied access: user ${(authSession.user as { id?: string }).id ?? 'unknown'} role=${userRoleName ?? 'n/a'} lacks permission mask=${requiredMask} on ${method} ${path}`,
|
||||
`Denied access: session tenant=${tenantId ?? 'n/a'} does not match context tenant=${tenantContext.tenant.id} for ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
await applyTenantIsolationContext({
|
||||
tenantId: tenantContext.tenant.id,
|
||||
isSuperAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveTenantIdForSession(authSession: AuthSession, method: string, path: string): Promise<string> {
|
||||
let sessionTenantId = (authSession.user as { tenantId?: string | null }).tenantId ?? null
|
||||
|
||||
if (sessionTenantId) {
|
||||
return sessionTenantId
|
||||
}
|
||||
|
||||
if (!authSession.user) {
|
||||
this.log.warn(`Denied access: session user missing for ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
.select({ tenantId: authUsers.tenantId })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, authSession.user.id))
|
||||
.limit(1)
|
||||
|
||||
sessionTenantId = record?.tenantId ?? ''
|
||||
|
||||
if (!sessionTenantId) {
|
||||
this.log.warn(
|
||||
`Denied access: session ${(authSession.user as { id?: string }).id ?? 'unknown'} missing tenant id for ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
}
|
||||
|
||||
return sessionTenantId
|
||||
}
|
||||
|
||||
private enforceRoleRequirements(
|
||||
handler: ReturnType<ExecutionContext['getHandler']>,
|
||||
authContext: HttpContextAuth | undefined,
|
||||
method: string,
|
||||
path: string,
|
||||
): void {
|
||||
const requiredMask = getAllowedRoleMask(handler)
|
||||
if (requiredMask === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!authContext?.user || !authContext.session) {
|
||||
this.log.warn(`Denied access: missing session for protected resource ${method} ${path}`)
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const userRoleName = (authContext.user as { role?: string }).role as
|
||||
| 'user'
|
||||
| 'admin'
|
||||
| 'superadmin'
|
||||
| 'guest'
|
||||
| undefined
|
||||
const userMask = userRoleName ? roleNameToBit(userRoleName) : 0
|
||||
const hasRole = (requiredMask & userMask) !== 0
|
||||
if (!hasRole) {
|
||||
this.log.warn(
|
||||
`Denied access: user ${(authContext.user as { id?: string }).id ?? 'unknown'} role=${userRoleName ?? 'n/a'} lacks permission mask=${requiredMask} on ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { HttpMiddleware, OnModuleDestroy, OnModuleInit } from '@afilmory/framework'
|
||||
import { EventEmitterService, Middleware } from '@afilmory/framework'
|
||||
import { OnboardingService } from 'core/modules/onboarding/onboarding.service'
|
||||
import type { Context } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { logger } from '../helpers/logger.helper'
|
||||
import { AppStateService } from '../modules/app-state/app-state.service'
|
||||
import { SettingService } from '../modules/setting/setting.service'
|
||||
import { getTenantContext } from '../modules/tenant/tenant.context'
|
||||
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
|
||||
@@ -52,7 +52,7 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
|
||||
private readonly eventEmitter: EventEmitterService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly tenantContextResolver: TenantContextResolver,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly appState: AppStateService,
|
||||
) {}
|
||||
|
||||
private readonly corsMiddleware = cors({
|
||||
@@ -121,7 +121,7 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
|
||||
}
|
||||
|
||||
['use']: HttpMiddleware['use'] = async (context, next) => {
|
||||
const initialized = await this.onboardingService.isInitialized()
|
||||
const initialized = await this.appState.isInitialized()
|
||||
|
||||
if (!initialized) {
|
||||
this.logger.info(`Application not initialized yet, skip CORS middleware for ${context.req.path}`)
|
||||
|
||||
80
be/apps/core/src/middlewares/request-context.middleware.ts
Normal file
80
be/apps/core/src/middlewares/request-context.middleware.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { HttpMiddleware } from '@afilmory/framework'
|
||||
import { HttpContext, Middleware } from '@afilmory/framework'
|
||||
import type { Context, Next } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { logger } from '../helpers/logger.helper'
|
||||
import type { AuthSession } from '../modules/auth/auth.provider'
|
||||
import { AuthProvider } from '../modules/auth/auth.provider'
|
||||
import { getTenantContext } from '../modules/tenant/tenant.context'
|
||||
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
|
||||
|
||||
@Middleware({ priority: -1 })
|
||||
@injectable()
|
||||
export class RequestContextMiddleware implements HttpMiddleware {
|
||||
private readonly log = logger.extend('RequestContextMiddleware')
|
||||
|
||||
constructor(
|
||||
private readonly tenantContextResolver: TenantContextResolver,
|
||||
private readonly authProvider: AuthProvider,
|
||||
) {}
|
||||
|
||||
async use(context: Context, next: Next): Promise<Response | void> {
|
||||
await Promise.all([this.ensureTenantContext(context), this.ensureAuthContext(context)])
|
||||
return await next()
|
||||
}
|
||||
|
||||
private async ensureTenantContext(context: Context): Promise<void> {
|
||||
if (getTenantContext()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
||||
setResponseHeaders: false,
|
||||
throwOnMissing: false,
|
||||
skipInitializationCheck: true,
|
||||
})
|
||||
if (tenantContext) {
|
||||
HttpContext.setValue('tenant', tenantContext)
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to resolve tenant context for ${context.req.method} ${context.req.path}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAuthContext(context: Context): Promise<void> {
|
||||
const authSession = await this.resolveAuthSession(context)
|
||||
if (!authSession) {
|
||||
return
|
||||
}
|
||||
|
||||
HttpContext.assign({
|
||||
auth: {
|
||||
user: authSession.user,
|
||||
session: authSession.session,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveAuthSession(context: Context): Promise<AuthSession | null> {
|
||||
try {
|
||||
const { headers } = context.req.raw
|
||||
const globalAuth = await this.authProvider.getAuth()
|
||||
const authSession = await globalAuth.api.getSession({ headers })
|
||||
|
||||
if (authSession) {
|
||||
this.log.verbose(
|
||||
`Session detected for user ${(authSession.user as { id?: string }).id ?? 'unknown'} on ${context.req.method} ${context.req.path}`,
|
||||
)
|
||||
} else {
|
||||
this.log.verbose(`No active session for ${context.req.method} ${context.req.path}`)
|
||||
}
|
||||
|
||||
return authSession
|
||||
} catch (error) {
|
||||
this.log.error('Failed to resolve auth session from middleware', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
10
be/apps/core/src/modules/app-state/app-state.module.ts
Normal file
10
be/apps/core/src/modules/app-state/app-state.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { DatabaseModule } from '../../database/database.module'
|
||||
import { AppStateService } from './app-state.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [AppStateService],
|
||||
})
|
||||
export class AppStateModule {}
|
||||
58
be/apps/core/src/modules/app-state/app-state.service.ts
Normal file
58
be/apps/core/src/modules/app-state/app-state.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { systemSettings } from '@afilmory/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
|
||||
const APP_INITIALIZED_KEY = 'system.app.initialized'
|
||||
|
||||
@injectable()
|
||||
export class AppStateService {
|
||||
private cachedInitialized: boolean | null = null
|
||||
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
|
||||
async isInitialized(): Promise<boolean> {
|
||||
if (this.cachedInitialized) {
|
||||
return true
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, APP_INITIALIZED_KEY))
|
||||
.limit(1)
|
||||
|
||||
const initialized = record?.value === true
|
||||
if (initialized) {
|
||||
this.cachedInitialized = true
|
||||
}
|
||||
return initialized
|
||||
}
|
||||
|
||||
async markInitialized(): Promise<void> {
|
||||
if (this.cachedInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: APP_INITIALIZED_KEY,
|
||||
value: true,
|
||||
isSensitive: false,
|
||||
description: 'Indicates whether the application completed its initial setup.',
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
this.cachedInitialized = true
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
@@ -13,6 +14,7 @@ import { getTenantContext } from '../tenant/tenant.context'
|
||||
import { TenantRepository } from '../tenant/tenant.repository'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import type { TenantRecord } from '../tenant/tenant.types'
|
||||
import type { AuthSession } from './auth.provider'
|
||||
import { AuthProvider } from './auth.provider'
|
||||
|
||||
type RegisterTenantAccountInput = {
|
||||
@@ -22,12 +24,13 @@ type RegisterTenantAccountInput = {
|
||||
}
|
||||
|
||||
type RegisterTenantInput = {
|
||||
account: RegisterTenantAccountInput
|
||||
account?: RegisterTenantAccountInput
|
||||
tenant?: {
|
||||
name: string
|
||||
slug?: string | null
|
||||
}
|
||||
settings?: Array<{ key: string; value: unknown }>
|
||||
useSessionAccount?: boolean
|
||||
}
|
||||
|
||||
export interface RegisterTenantResult {
|
||||
@@ -62,9 +65,21 @@ export class AuthRegistrationService {
|
||||
await this.systemSettings.ensureRegistrationAllowed()
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
const account = this.normalizeAccountInput(input.account)
|
||||
const account = input.account ? this.normalizeAccountInput(input.account) : null
|
||||
const useSessionAccount = input.useSessionAccount ?? false
|
||||
const sessionUser = this.getSessionUser()
|
||||
|
||||
if (useSessionAccount && !sessionUser) {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '请先登录后再创建工作区' })
|
||||
}
|
||||
|
||||
if (tenantContext) {
|
||||
if (useSessionAccount) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前租户上下文下不支持会话注册' })
|
||||
}
|
||||
if (!account) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少注册账号信息' })
|
||||
}
|
||||
return await this.registerExistingTenantMember(account, headers, tenantContext.tenant)
|
||||
}
|
||||
|
||||
@@ -72,7 +87,7 @@ export class AuthRegistrationService {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户信息不能为空' })
|
||||
}
|
||||
|
||||
return await this.registerNewTenant(account, input.tenant, headers, input.settings)
|
||||
return await this.registerNewTenant(account, input.tenant, headers, input.settings, useSessionAccount)
|
||||
}
|
||||
|
||||
private async generateUniqueSlug(base: string): Promise<string> {
|
||||
@@ -197,10 +212,11 @@ export class AuthRegistrationService {
|
||||
}
|
||||
|
||||
private async registerNewTenant(
|
||||
account: Required<RegisterTenantAccountInput>,
|
||||
account: RegisterTenantAccountInput | null,
|
||||
tenantInput: RegisterTenantInput['tenant'],
|
||||
headers: Headers,
|
||||
settings?: RegisterTenantInput['settings'],
|
||||
useSessionAccount?: boolean,
|
||||
): Promise<RegisterTenantResult> {
|
||||
const tenantName = tenantInput?.name?.trim() ?? ''
|
||||
if (!tenantName) {
|
||||
@@ -216,59 +232,71 @@ export class AuthRegistrationService {
|
||||
|
||||
let tenantId: string | null = null
|
||||
try {
|
||||
const sessionUser = useSessionAccount ? this.getSessionUser() : null
|
||||
if (!account && !sessionUser) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少注册账号信息' })
|
||||
}
|
||||
|
||||
const tenantAggregate = await this.tenantService.createTenant({
|
||||
name: tenantName,
|
||||
slug,
|
||||
})
|
||||
tenantId = tenantAggregate.tenant.id
|
||||
|
||||
const auth = await this.authProvider.getAuth()
|
||||
const response = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
name: account.name,
|
||||
},
|
||||
headers,
|
||||
asResponse: true,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (tenantId) {
|
||||
await this.tenantService.deleteTenant(tenantId).catch(() => {})
|
||||
tenantId = null
|
||||
}
|
||||
return { response, success: false }
|
||||
}
|
||||
|
||||
let response: Response | null = null
|
||||
let userId: string | undefined
|
||||
try {
|
||||
const payload = (await response.clone().json()) as { user?: { id?: string } } | null
|
||||
userId = payload?.user?.id
|
||||
} catch {
|
||||
userId = undefined
|
||||
}
|
||||
const db = this.dbAccessor.get()
|
||||
|
||||
if (!userId) {
|
||||
if (tenantId) {
|
||||
if (account) {
|
||||
const auth = await this.authProvider.getAuth()
|
||||
const signupResponse = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
name: account.name,
|
||||
},
|
||||
headers,
|
||||
asResponse: true,
|
||||
})
|
||||
|
||||
if (!signupResponse.ok) {
|
||||
await this.tenantService.deleteTenant(tenantId).catch(() => {})
|
||||
tenantId = null
|
||||
return { response: signupResponse, success: false }
|
||||
}
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '注册成功但未返回用户信息,请稍后重试。',
|
||||
|
||||
try {
|
||||
const payload = (await signupResponse.clone().json()) as { user?: { id?: string } } | null
|
||||
userId = payload?.user?.id
|
||||
} catch {
|
||||
userId = undefined
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
await this.tenantService.deleteTenant(tenantId).catch(() => {})
|
||||
tenantId = null
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '注册成功但未返回用户信息,请稍后重试。',
|
||||
})
|
||||
}
|
||||
|
||||
await db.update(authUsers).set({ tenantId, role: 'admin' }).where(eq(authUsers.id, userId))
|
||||
response = signupResponse
|
||||
} else if (sessionUser && tenantId) {
|
||||
userId = await this.attachSessionUserToTenant(tenantId)
|
||||
response = new Response(JSON.stringify({ user: { id: userId } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
await db.update(authUsers).set({ tenantId, role: 'admin' }).where(eq(authUsers.id, userId))
|
||||
|
||||
const initialSettings = this.normalizeSettings(settings)
|
||||
if (initialSettings.length > 0) {
|
||||
if (initialSettings.length > 0 && tenantId) {
|
||||
await this.settingService.setMany(
|
||||
initialSettings.map((entry) => ({
|
||||
...entry,
|
||||
options: {
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
tenantId,
|
||||
isSensitive: false,
|
||||
},
|
||||
})),
|
||||
@@ -278,7 +306,7 @@ export class AuthRegistrationService {
|
||||
const refreshed = await this.tenantService.getById(tenantId)
|
||||
|
||||
return {
|
||||
response,
|
||||
response: response ?? new Response(null, { status: 200 }),
|
||||
tenant: refreshed.tenant,
|
||||
accountId: userId,
|
||||
success: true,
|
||||
@@ -290,4 +318,50 @@ export class AuthRegistrationService {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionUser(): AuthSession['user'] | null {
|
||||
try {
|
||||
const auth = HttpContext.getValue('auth') as { user?: AuthSession['user'] } | undefined
|
||||
return auth?.user ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async attachSessionUserToTenant(tenantId: string): Promise<string> {
|
||||
const sessionUser = this.getSessionUser()
|
||||
const sessionUserId = (sessionUser as { id?: string } | null)?.id
|
||||
if (!sessionUserId) {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '当前登录状态无效,请重新登录。' })
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
.select({ tenantId: authUsers.tenantId })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, sessionUserId))
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '无法找到当前用户信息。' })
|
||||
}
|
||||
|
||||
if (record.tenantId) {
|
||||
const isPlaceholder = await this.tenantService.isPlaceholderTenantId(record.tenantId)
|
||||
if (!isPlaceholder) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前账号已属于其它工作区,无法重复注册。' })
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
tenantId,
|
||||
role: 'admin',
|
||||
name: sessionUser.name ?? sessionUser.email ?? 'Workspace Admin',
|
||||
})
|
||||
.where(eq(authUsers.id, sessionUserId))
|
||||
|
||||
return sessionUserId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { Body, ContextParam, Controller, Get, HttpContext, Post, UnauthorizedException } from '@afilmory/framework'
|
||||
import { freshSessionMiddleware } from 'better-auth/api'
|
||||
import { SkipTenant } from 'core/decorators/skip-tenant.decorator'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Context } from 'hono'
|
||||
@@ -61,6 +62,7 @@ type TenantSignUpRequest = {
|
||||
slug?: string | null
|
||||
}
|
||||
settings?: Array<{ key?: string; value?: unknown }>
|
||||
useSessionAccount?: boolean
|
||||
}
|
||||
|
||||
type SocialSignInRequest = {
|
||||
@@ -258,9 +260,12 @@ export class AuthController {
|
||||
return response
|
||||
}
|
||||
|
||||
@SkipTenant()
|
||||
@Post('/sign-up/email')
|
||||
async signUpEmail(@ContextParam() context: Context, @Body() body: TenantSignUpRequest) {
|
||||
if (!body?.account) {
|
||||
const useSessionAccount = body?.useSessionAccount ?? false
|
||||
|
||||
if (!body?.account && !useSessionAccount) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少注册账号信息' })
|
||||
}
|
||||
|
||||
@@ -268,16 +273,21 @@ export class AuthController {
|
||||
if (!tenantContext && !body.tenant) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少租户信息' })
|
||||
}
|
||||
if (tenantContext && useSessionAccount) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前操作不支持使用已登录账号' })
|
||||
}
|
||||
|
||||
const headers = this.buildTenantAwareHeaders(context)
|
||||
|
||||
const result = await this.registration.registerTenant(
|
||||
{
|
||||
account: {
|
||||
email: body.account.email ?? '',
|
||||
password: body.account.password ?? '',
|
||||
name: body.account.name ?? '',
|
||||
},
|
||||
account: body.account
|
||||
? {
|
||||
email: body.account.email ?? '',
|
||||
password: body.account.password ?? '',
|
||||
name: body.account.name ?? '',
|
||||
}
|
||||
: undefined,
|
||||
tenant: body.tenant
|
||||
? {
|
||||
name: body.tenant.name ?? '',
|
||||
@@ -287,6 +297,7 @@ export class AuthController {
|
||||
settings: body.settings?.filter(
|
||||
(s): s is { key: string; value: unknown } => typeof s.key === 'string' && s.key.length > 0,
|
||||
),
|
||||
useSessionAccount,
|
||||
},
|
||||
headers,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
import { DatabaseModule } from 'core/database/database.module'
|
||||
|
||||
import { AppStateModule } from '../app-state/app-state.module'
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { SystemSettingModule } from '../system-setting/system-setting.module'
|
||||
import { TenantModule } from '../tenant/tenant.module'
|
||||
@@ -8,10 +9,11 @@ import { AuthConfig } from './auth.config'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { AuthProvider } from './auth.provider'
|
||||
import { AuthRegistrationService } from './auth-registration.service'
|
||||
import { RootAccountProvisioner } from './root-account.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, SystemSettingModule, SettingModule, TenantModule],
|
||||
imports: [DatabaseModule, SystemSettingModule, SettingModule, TenantModule, AppStateModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthProvider, AuthConfig, AuthRegistrationService],
|
||||
providers: [AuthProvider, AuthConfig, AuthRegistrationService, RootAccountProvisioner],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { injectable } from 'tsyringe'
|
||||
|
||||
import { DrizzleProvider } from '../../database/database.provider'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
|
||||
import { AuthConfig } from './auth.config'
|
||||
|
||||
@@ -22,11 +23,13 @@ const logger = createLogger('Auth')
|
||||
export class AuthProvider implements OnModuleInit {
|
||||
private moduleOptionsPromise?: Promise<AuthModuleOptions>
|
||||
private instances = new Map<string, Promise<BetterAuthInstance>>()
|
||||
private placeholderTenantId: string | null = null
|
||||
|
||||
constructor(
|
||||
private readonly config: AuthConfig,
|
||||
private readonly drizzleProvider: DrizzleProvider,
|
||||
private readonly systemSettings: SystemSettingService,
|
||||
private readonly tenantService: TenantService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -75,6 +78,20 @@ export class AuthProvider implements OnModuleInit {
|
||||
return this.moduleOptionsPromise
|
||||
}
|
||||
|
||||
private async resolveFallbackTenantId(): Promise<string | null> {
|
||||
if (this.placeholderTenantId) {
|
||||
return this.placeholderTenantId
|
||||
}
|
||||
try {
|
||||
const placeholder = await this.tenantService.ensurePlaceholderTenant()
|
||||
this.placeholderTenantId = placeholder.tenant.id
|
||||
return this.placeholderTenantId
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure placeholder tenant', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRequestEndpoint(): { host: string | null; protocol: string | null } {
|
||||
try {
|
||||
const hono = HttpContext.getValue('hono') as Context | undefined
|
||||
@@ -196,14 +213,25 @@ export class AuthProvider implements OnModuleInit {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
const tenantId = this.resolveTenantIdFromContext()
|
||||
if (!tenantId) {
|
||||
if (tenantId) {
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
tenantId,
|
||||
role: user.role ?? 'guest',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackTenantId = await this.resolveFallbackTenantId()
|
||||
if (!fallbackTenantId) {
|
||||
return { data: user }
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
tenantId,
|
||||
tenantId: fallbackTenantId,
|
||||
role: user.role ?? 'guest',
|
||||
},
|
||||
}
|
||||
@@ -214,10 +242,11 @@ export class AuthProvider implements OnModuleInit {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
const tenantId = this.resolveTenantIdFromContext()
|
||||
const fallbackTenantId = tenantId ?? session.tenantId ?? (await this.resolveFallbackTenantId())
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
tenantId: tenantId ?? session.tenantId ?? null,
|
||||
tenantId: fallbackTenantId ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -227,14 +256,15 @@ export class AuthProvider implements OnModuleInit {
|
||||
create: {
|
||||
before: async (account) => {
|
||||
const tenantId = this.resolveTenantIdFromContext()
|
||||
if (!tenantId) {
|
||||
const resolvedTenantId = tenantId ?? (await this.resolveFallbackTenantId())
|
||||
if (!resolvedTenantId) {
|
||||
return { data: account }
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...account,
|
||||
tenantId,
|
||||
tenantId: resolvedTenantId,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
123
be/apps/core/src/modules/auth/root-account.service.ts
Normal file
123
be/apps/core/src/modules/auth/root-account.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { env } from '@afilmory/env'
|
||||
import type { OnModuleInit } from '@afilmory/framework'
|
||||
import { createLogger } from '@afilmory/framework'
|
||||
import { AppStateService } from 'core/modules/app-state/app-state.service'
|
||||
import { STATIC_DASHBOARD_BASENAME } from 'core/modules/static-web/static-dashboard.service'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { AuthProvider } from './auth.provider'
|
||||
|
||||
const log = createLogger('RootAccount')
|
||||
|
||||
@injectable()
|
||||
export class RootAccountProvisioner implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly authProvider: AuthProvider,
|
||||
private readonly appState: AppStateService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const initialized = await this.appState.isInitialized()
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
await this.ensureRootAccount()
|
||||
}
|
||||
|
||||
private async ensureRootAccount(): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
const email = env.DEFAULT_SUPERADMIN_EMAIL
|
||||
const username = env.DEFAULT_SUPERADMIN_USERNAME
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: authUsers.id, role: authUsers.role })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
if (existing.role !== 'superadmin') {
|
||||
await db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
role: 'superadmin',
|
||||
tenantId: null,
|
||||
name: username,
|
||||
username,
|
||||
displayUsername: username,
|
||||
})
|
||||
.where(eq(authUsers.id, existing.id))
|
||||
log.info(`Existing account ${email} promoted to superadmin`)
|
||||
} else {
|
||||
log.info('Root account already exists, skipping provisioning')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const password = randomBytes(16).toString('base64url')
|
||||
|
||||
try {
|
||||
const auth = await this.authProvider.getAuth()
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name: username,
|
||||
},
|
||||
})
|
||||
|
||||
const userId = result.user.id
|
||||
|
||||
await db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
role: 'superadmin',
|
||||
tenantId: null,
|
||||
name: username,
|
||||
username,
|
||||
displayUsername: username,
|
||||
})
|
||||
.where(eq(authUsers.id, userId))
|
||||
|
||||
this.printRootInstructions(email, username, password)
|
||||
} catch (error) {
|
||||
log.error('Failed to provision root account', error)
|
||||
}
|
||||
}
|
||||
|
||||
private printRootInstructions(email: string, username: string, password: string): void {
|
||||
const urls = this.buildDashboardUrls()
|
||||
const lines = [
|
||||
'',
|
||||
'============================================================',
|
||||
'Root dashboard access provisioned.',
|
||||
` Dashboard URL: ${urls.shift()}`,
|
||||
...(urls.length > 0 ? urls.map((url) => ` Alternate URL: ${url}`) : []),
|
||||
` Email: ${email}`,
|
||||
` Username: ${username}`,
|
||||
` Password: ${password}`,
|
||||
'============================================================',
|
||||
'',
|
||||
]
|
||||
lines.forEach((line) => process.stdout.write(`${line}\n`))
|
||||
}
|
||||
|
||||
private buildDashboardUrls(): string[] {
|
||||
const port = env.PORT ?? 3000
|
||||
const primaryHost = 'localhost'
|
||||
const configuredHost = env.HOSTNAME?.trim() ?? ''
|
||||
|
||||
const hosts = new Set<string>([primaryHost])
|
||||
if (configuredHost && configuredHost !== '0.0.0.0' && configuredHost !== primaryHost) {
|
||||
hosts.add(configuredHost)
|
||||
}
|
||||
|
||||
return Array.from(hosts).map((host) => `http://${host}:${port}${STATIC_DASHBOARD_BASENAME}`)
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,18 @@ import { AuthGuard } from 'core/guards/auth.guard'
|
||||
import { TenantResolverInterceptor } from 'core/interceptors/tenant-resolver.interceptor'
|
||||
import { CorsMiddleware } from 'core/middlewares/cors.middleware'
|
||||
import { DatabaseContextMiddleware } from 'core/middlewares/database-context.middleware'
|
||||
import { RequestContextMiddleware } from 'core/middlewares/request-context.middleware'
|
||||
import { RedisAccessor } from 'core/redis/redis.provider'
|
||||
|
||||
import { DatabaseModule } from '../database/database.module'
|
||||
import { RedisModule } from '../redis/redis.module'
|
||||
import { AppStateModule } from './app-state/app-state.module'
|
||||
import { AuthModule } from './auth/auth.module'
|
||||
import { CacheModule } from './cache/cache.module'
|
||||
import { DashboardModule } from './dashboard/dashboard.module'
|
||||
import { DataSyncModule } from './data-sync/data-sync.module'
|
||||
import { FeedModule } from './feed/feed.module'
|
||||
import { OgModule } from './og/og.module'
|
||||
import { OnboardingModule } from './onboarding/onboarding.module'
|
||||
import { PhotoModule } from './photo/photo.module'
|
||||
import { ReactionModule } from './reaction/reaction.module'
|
||||
import { SettingModule } from './setting/setting.module'
|
||||
@@ -33,6 +34,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
AppStateModule,
|
||||
EventModule.forRootAsync({
|
||||
useFactory: createEventModuleOptions,
|
||||
inject: [RedisAccessor],
|
||||
@@ -45,7 +47,6 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
SiteSettingModule,
|
||||
SystemSettingModule,
|
||||
SuperAdminModule,
|
||||
OnboardingModule,
|
||||
PhotoModule,
|
||||
ReactionModule,
|
||||
DashboardModule,
|
||||
@@ -58,6 +59,10 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
StaticWebModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_MIDDLEWARE,
|
||||
useClass: RequestContextMiddleware,
|
||||
},
|
||||
{
|
||||
provide: APP_MIDDLEWARE,
|
||||
useClass: CorsMiddleware,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Body, Controller, Get, Post } from '@afilmory/framework'
|
||||
import { SkipTenant } from 'core/decorators/skip-tenant.decorator'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
|
||||
import { OnboardingInitDto } from './onboarding.dto'
|
||||
import { OnboardingService } from './onboarding.service'
|
||||
|
||||
@Controller('onboarding')
|
||||
export class OnboardingController {
|
||||
constructor(private readonly service: OnboardingService) {}
|
||||
|
||||
@Get('/status')
|
||||
async getStatus() {
|
||||
const initialized = await this.service.isInitialized()
|
||||
return { initialized }
|
||||
}
|
||||
|
||||
@Get('/site-schema')
|
||||
@BypassResponseTransform()
|
||||
@SkipTenant()
|
||||
async getSiteSchema() {
|
||||
return await this.service.getSiteSchema()
|
||||
}
|
||||
|
||||
@Post('/init')
|
||||
async initialize(@Body() dto: OnboardingInitDto) {
|
||||
const initialized = await this.service.isInitialized()
|
||||
if (initialized) {
|
||||
throw new BizException(ErrorCode.COMMON_CONFLICT, { message: 'Already initialized' })
|
||||
}
|
||||
const result = await this.service.initialize(dto)
|
||||
return {
|
||||
ok: true,
|
||||
adminUserId: result.adminUserId,
|
||||
tenantId: result.tenantId,
|
||||
superAdminUserId: result.superAdminUserId,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createZodDto } from '@afilmory/framework'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SETTING_SCHEMAS, SettingKeys } from '../setting/setting.constant'
|
||||
|
||||
const adminSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^(?!root$)/i, { message: 'Name "root" is reserved' }),
|
||||
})
|
||||
|
||||
const keySchema = z.enum(SettingKeys)
|
||||
|
||||
const settingEntrySchema = z.object({
|
||||
key: keySchema,
|
||||
value: z.unknown(),
|
||||
})
|
||||
|
||||
const normalizeEntries = z
|
||||
.union([settingEntrySchema, z.object({ entries: z.array(settingEntrySchema).min(1) })])
|
||||
.transform((payload) => {
|
||||
const entries = 'entries' in payload ? payload.entries : [payload]
|
||||
return entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
|
||||
}))
|
||||
})
|
||||
|
||||
export class OnboardingInitDto extends createZodDto(
|
||||
z.object({
|
||||
admin: adminSchema,
|
||||
tenant: z.object({
|
||||
name: z.string().min(1),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9-]+$/, { message: 'Slug should be lowercase alphanumeric with hyphen' }),
|
||||
}),
|
||||
settings: normalizeEntries.optional().transform((entries) => entries ?? []),
|
||||
}),
|
||||
) {}
|
||||
|
||||
export type NormalizedSettingEntry = {
|
||||
key: z.infer<typeof keySchema>
|
||||
value: unknown
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { DatabaseModule } from '../../database/database.module'
|
||||
import { AuthModule } from '../auth/auth.module'
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { SiteSettingModule } from '../site-setting/site-setting.module'
|
||||
import { TenantModule } from '../tenant/tenant.module'
|
||||
import { OnboardingController } from './onboarding.controller'
|
||||
import { OnboardingService } from './onboarding.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, SettingModule, SiteSettingModule, TenantModule],
|
||||
providers: [OnboardingService],
|
||||
controllers: [OnboardingController],
|
||||
})
|
||||
export class OnboardingModule {}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { env } from '@afilmory/env'
|
||||
import { createLogger } from '@afilmory/framework'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { AuthProvider } from '../auth/auth.provider'
|
||||
import { SettingService } from '../setting/setting.service'
|
||||
import { SiteSettingService } from '../site-setting/site-setting.service'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import type { NormalizedSettingEntry, OnboardingInitDto } from './onboarding.dto'
|
||||
|
||||
const log = createLogger('Onboarding')
|
||||
|
||||
@injectable()
|
||||
export class OnboardingService {
|
||||
constructor(
|
||||
private readonly db: DbAccessor,
|
||||
private readonly auth: AuthProvider,
|
||||
private readonly settings: SettingService,
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly siteSettingService: SiteSettingService,
|
||||
) {}
|
||||
|
||||
async isInitialized(): Promise<boolean> {
|
||||
const db = this.db.get()
|
||||
const [user] = await db.select().from(authUsers).limit(1)
|
||||
return Boolean(user)
|
||||
}
|
||||
|
||||
async getSiteSchema() {
|
||||
return await this.siteSettingService.getOnboardingUiSchema()
|
||||
}
|
||||
|
||||
async initialize(
|
||||
payload: OnboardingInitDto,
|
||||
): Promise<{ adminUserId: string; superAdminUserId: string; tenantId: string }> {
|
||||
const already = await this.isInitialized()
|
||||
if (already) {
|
||||
throw new BizException(ErrorCode.COMMON_CONFLICT, { message: 'Application already initialized' })
|
||||
}
|
||||
const db = this.db.get()
|
||||
|
||||
// Create first tenant
|
||||
const tenantAggregate = await this.tenantService.createTenant({
|
||||
name: payload.tenant.name,
|
||||
slug: payload.tenant.slug,
|
||||
})
|
||||
|
||||
log.info(`Created tenant ${tenantAggregate.tenant.slug} (${tenantAggregate.tenant.id})`)
|
||||
|
||||
// Apply initial settings to tenant
|
||||
const entries = (payload.settings as unknown as NormalizedSettingEntry[]) ?? []
|
||||
if (entries.length > 0) {
|
||||
const entriesWithTenant = entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
options: { tenantId: tenantAggregate.tenant.id },
|
||||
})) as Parameters<SettingService['setMany']>[0]
|
||||
await this.settings.setMany(entriesWithTenant)
|
||||
}
|
||||
|
||||
const auth = await this.auth.getAuth()
|
||||
|
||||
// Create initial admin for this tenant
|
||||
const adminResult = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: payload.admin.email,
|
||||
password: payload.admin.password,
|
||||
name: payload.admin.name,
|
||||
// @ts-expect-error - tenantId is not part of the signUpEmail body
|
||||
tenantId: tenantAggregate.tenant.id,
|
||||
},
|
||||
})
|
||||
|
||||
const adminUserId = adminResult.user.id
|
||||
|
||||
await db
|
||||
.update(authUsers)
|
||||
.set({ role: 'admin', tenantId: tenantAggregate.tenant.id })
|
||||
.where(eq(authUsers.id, adminUserId))
|
||||
|
||||
log.info(`Provisioned tenant admin ${adminUserId} for tenant ${tenantAggregate.tenant.slug}`)
|
||||
|
||||
// Create global superadmin account
|
||||
const superPassword = this.generatePassword()
|
||||
const superEmail = env.DEFAULT_SUPERADMIN_EMAIL
|
||||
const superUsername = env.DEFAULT_SUPERADMIN_USERNAME
|
||||
|
||||
const superResult = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: superEmail,
|
||||
password: superPassword,
|
||||
name: superUsername,
|
||||
},
|
||||
})
|
||||
|
||||
const superAdminId = superResult.user.id
|
||||
|
||||
await db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
role: 'superadmin',
|
||||
tenantId: null,
|
||||
name: superUsername,
|
||||
username: superUsername,
|
||||
displayUsername: superUsername,
|
||||
})
|
||||
.where(eq(authUsers.id, superAdminId))
|
||||
|
||||
log.info(`Superadmin account created: ${superUsername} (${superAdminId})`)
|
||||
process.stdout.write(
|
||||
`Superadmin credentials -> email: ${superEmail} username: ${superUsername} password: ${superPassword}\n`,
|
||||
)
|
||||
|
||||
return { adminUserId, superAdminUserId: superAdminId, tenantId: tenantAggregate.tenant.id }
|
||||
}
|
||||
|
||||
private generatePassword(): string {
|
||||
return randomBytes(16).toString('base64url')
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { Module } from '@afilmory/framework'
|
||||
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { SiteSettingController } from './site-setting.controller'
|
||||
import { SiteSettingPublicController } from './site-setting.public.controller'
|
||||
import { SiteSettingService } from './site-setting.service'
|
||||
|
||||
@Module({
|
||||
imports: [SettingModule],
|
||||
controllers: [SiteSettingController],
|
||||
controllers: [SiteSettingController, SiteSettingPublicController],
|
||||
providers: [SiteSettingService],
|
||||
exports: [SiteSettingService],
|
||||
})
|
||||
export class SiteSettingModule {}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get } from '@afilmory/framework'
|
||||
import { SkipTenant } from 'core/decorators/skip-tenant.decorator'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
|
||||
import { SiteSettingService } from './site-setting.service'
|
||||
|
||||
@Controller('public/site-settings')
|
||||
@SkipTenant()
|
||||
export class SiteSettingPublicController {
|
||||
constructor(private readonly siteSettingService: SiteSettingService) {}
|
||||
|
||||
@Get('/welcome-schema')
|
||||
@BypassResponseTransform()
|
||||
async getWelcomeSchema() {
|
||||
return await this.siteSettingService.getOnboardingUiSchema()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { logger } from '../../helpers/logger.helper'
|
||||
import { OnboardingService } from '../onboarding/onboarding.service'
|
||||
import { AppStateService } from '../app-state/app-state.service'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
import { TenantService } from './tenant.service'
|
||||
import type { TenantContext } from './tenant.types'
|
||||
@@ -25,7 +25,7 @@ export class TenantContextResolver {
|
||||
|
||||
constructor(
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly appState: AppStateService,
|
||||
private readonly systemSettingService: SystemSettingService,
|
||||
) {}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class TenantContextResolver {
|
||||
}
|
||||
|
||||
if (!options.skipInitializationCheck) {
|
||||
const initialized = await this.onboardingService.isInitialized()
|
||||
const initialized = await this.appState.isInitialized()
|
||||
if (!initialized) {
|
||||
this.log.info(`Application not initialized yet, skip tenant resolution for ${context.req.path}`)
|
||||
return null
|
||||
@@ -54,17 +54,16 @@ export class TenantContextResolver {
|
||||
const tenantId = this.normalizeString(context.req.header(HEADER_TENANT_ID))
|
||||
const tenantSlugHeader = this.normalizeSlug(context.req.header(HEADER_TENANT_SLUG))
|
||||
|
||||
this.log.verbose(
|
||||
`Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, id=${tenantId ?? 'n/a'}, slug=${tenantSlugHeader ?? 'n/a'})`,
|
||||
)
|
||||
|
||||
const baseDomain = await this.getBaseDomain()
|
||||
|
||||
let derivedSlug = tenantSlugHeader
|
||||
let derivedSlug = host ? this.extractSlugFromHost(host, baseDomain) : undefined
|
||||
|
||||
if (!derivedSlug && host) {
|
||||
derivedSlug = this.extractSlugFromHost(host, baseDomain)
|
||||
if (!derivedSlug) {
|
||||
derivedSlug = tenantSlugHeader
|
||||
}
|
||||
this.log.verbose(
|
||||
`Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, id=${tenantId ?? 'n/a'}, slug=${derivedSlug ?? 'n/a'})`,
|
||||
)
|
||||
|
||||
const tenantContext = await this.tenantService.resolve(
|
||||
{
|
||||
@@ -81,8 +80,6 @@ export class TenantContextResolver {
|
||||
return null
|
||||
}
|
||||
|
||||
HttpContext.setValue('tenant', tenantContext)
|
||||
|
||||
if (options.setResponseHeaders !== false) {
|
||||
this.applyTenantHeaders(context, tenantContext)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,6 @@ import { BizException, ErrorCode } from 'core/errors'
|
||||
|
||||
import type { TenantContext } from './tenant.types'
|
||||
|
||||
declare module '@afilmory/framework' {
|
||||
interface HttpContextValues {
|
||||
tenant?: TenantContext
|
||||
}
|
||||
}
|
||||
|
||||
export function getTenantContext<TRequired extends boolean = false>(options?: {
|
||||
required?: TRequired
|
||||
}): TRequired extends true ? TenantContext : TenantContext | undefined {
|
||||
|
||||
@@ -3,12 +3,13 @@ import './tenant.context'
|
||||
import { Module } from '@afilmory/framework'
|
||||
import { DatabaseModule } from 'core/database/database.module'
|
||||
|
||||
import { AppStateModule } from '../app-state/app-state.module'
|
||||
import { TenantRepository } from './tenant.repository'
|
||||
import { TenantService } from './tenant.service'
|
||||
import { TenantContextResolver } from './tenant-context-resolver.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
imports: [DatabaseModule, AppStateModule],
|
||||
providers: [TenantRepository, TenantService, TenantContextResolver],
|
||||
})
|
||||
export class TenantModule {}
|
||||
|
||||
@@ -2,12 +2,19 @@ import { isTenantSlugReserved } from '@afilmory/utils'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { AppStateService } from '../app-state/app-state.service'
|
||||
import { TenantRepository } from './tenant.repository'
|
||||
import type { TenantAggregate, TenantContext, TenantResolutionInput } from './tenant.types'
|
||||
|
||||
const PLACEHOLDER_TENANT_SLUG = 'holding'
|
||||
const PLACEHOLDER_TENANT_NAME = 'Pending Workspace'
|
||||
|
||||
@injectable()
|
||||
export class TenantService {
|
||||
constructor(private readonly repository: TenantRepository) {}
|
||||
constructor(
|
||||
private readonly repository: TenantRepository,
|
||||
private readonly appState: AppStateService,
|
||||
) {}
|
||||
|
||||
async createTenant(payload: { name: string; slug: string }): Promise<TenantAggregate> {
|
||||
const normalizedSlug = this.normalizeSlug(payload.slug)
|
||||
@@ -22,11 +29,38 @@ export class TenantService {
|
||||
throw new BizException(ErrorCode.TENANT_SLUG_RESERVED)
|
||||
}
|
||||
|
||||
return await this.repository.createTenant({
|
||||
const aggregate = await this.repository.createTenant({
|
||||
...payload,
|
||||
slug: normalizedSlug,
|
||||
})
|
||||
await this.appState.markInitialized()
|
||||
return aggregate
|
||||
}
|
||||
|
||||
async ensurePlaceholderTenant(): Promise<TenantAggregate> {
|
||||
const existing = await this.repository.findBySlug(PLACEHOLDER_TENANT_SLUG)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return await this.repository.createTenant({
|
||||
name: PLACEHOLDER_TENANT_NAME,
|
||||
slug: PLACEHOLDER_TENANT_SLUG,
|
||||
})
|
||||
}
|
||||
|
||||
getPlaceholderTenantSlug(): string {
|
||||
return PLACEHOLDER_TENANT_SLUG
|
||||
}
|
||||
|
||||
async isPlaceholderTenantId(tenantId: string | null | undefined): Promise<boolean> {
|
||||
if (!tenantId) {
|
||||
return false
|
||||
}
|
||||
const placeholder = await this.ensurePlaceholderTenant()
|
||||
return placeholder.tenant.id === tenantId
|
||||
}
|
||||
|
||||
async resolve(input: TenantResolutionInput, noThrow: boolean): Promise<TenantContext | null>
|
||||
async resolve(input: TenantResolutionInput): Promise<TenantContext>
|
||||
async resolve(input: TenantResolutionInput, noThrow = false): Promise<TenantContext | null> {
|
||||
|
||||
@@ -60,8 +60,8 @@ export function UserMenu({ user }: UserMenuProps) {
|
||||
|
||||
{/* User Info - Hidden on small screens */}
|
||||
<div className="hidden text-left md:block">
|
||||
<div className="text-text text-[13px] font-medium leading-tight">{user.name || user.email}</div>
|
||||
<div className="text-text-tertiary text-[11px] capitalize leading-tight">{user.role}</div>
|
||||
<div className="text-text text-[13px] leading-tight font-medium">{user.name || user.email}</div>
|
||||
<div className="text-text-tertiary text-[11px] leading-tight capitalize">{user.role}</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron Icon */}
|
||||
@@ -73,7 +73,7 @@ export function UserMenu({ user }: UserMenuProps) {
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56 border-fill-tertiary bg-background shadow-lg"
|
||||
className="border-fill-tertiary bg-background w-56 shadow-lg"
|
||||
style={{
|
||||
backgroundImage: 'none',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
@@ -98,7 +98,7 @@ export function UserMenu({ user }: UserMenuProps) {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut} icon={<LogOut className="size-4 text-red" />}>
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut} icon={<LogOut className="text-red size-4" />}>
|
||||
<span className="text-red">{isLoggingOut ? 'Logging out...' : 'Log out'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -7,13 +7,9 @@ import { useSetAuthUser } from '~/atoms/auth'
|
||||
import type { SessionResponse } from '~/modules/auth/api/session'
|
||||
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
|
||||
import { signOutBySource } from '~/modules/auth/auth-client'
|
||||
import { getOnboardingStatus } from '~/modules/onboarding/api'
|
||||
|
||||
const ONBOARDING_STATUS_QUERY_KEY = ['onboarding', 'status'] as const
|
||||
|
||||
const DEFAULT_LOGIN_PATH = '/login'
|
||||
const DEFAULT_ONBOARDING_PATH = '/onboarding'
|
||||
const DEFAULT_REGISTER_PATH = '/register'
|
||||
const DEFAULT_WELCOME_PATH = '/welcome'
|
||||
const TENANT_MISSING_PATH = '/tenant-missing'
|
||||
const DEFAULT_AUTHENTICATED_PATH = '/'
|
||||
const SUPERADMIN_ROOT_PATH = '/superadmin'
|
||||
@@ -24,7 +20,7 @@ const AUTH_TENANT_NOT_FOUND_ERROR_CODE = 12
|
||||
const TENANT_NOT_FOUND_ERROR_CODE = 20
|
||||
const TENANT_MISSING_ERROR_CODES = new Set([AUTH_TENANT_NOT_FOUND_ERROR_CODE, TENANT_NOT_FOUND_ERROR_CODE])
|
||||
|
||||
const PUBLIC_PATHS = new Set([DEFAULT_LOGIN_PATH, DEFAULT_ONBOARDING_PATH, DEFAULT_REGISTER_PATH, TENANT_MISSING_PATH])
|
||||
const PUBLIC_PATHS = new Set([DEFAULT_LOGIN_PATH, DEFAULT_WELCOME_PATH, TENANT_MISSING_PATH])
|
||||
|
||||
type BizErrorPayload = { code?: number | string }
|
||||
type FetchErrorWithPayload = FetchError<BizErrorPayload> & {
|
||||
@@ -79,12 +75,6 @@ export function usePageRedirect() {
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const onboardingQuery = useQuery({
|
||||
queryKey: ONBOARDING_STATUS_QUERY_KEY,
|
||||
queryFn: getOnboardingStatus,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await signOutBySource()
|
||||
@@ -110,7 +100,7 @@ export function usePageRedirect() {
|
||||
}, [queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
const matchedTenantNotFound = [sessionQuery.error, onboardingQuery.error].some((error) => {
|
||||
const matchedTenantNotFound = [sessionQuery.error].some((error) => {
|
||||
const code = extractBizErrorCode(error)
|
||||
return code !== null && TENANT_MISSING_ERROR_CODES.has(code)
|
||||
})
|
||||
@@ -125,31 +115,22 @@ export function usePageRedirect() {
|
||||
if (location.pathname !== TENANT_MISSING_PATH) {
|
||||
navigate(TENANT_MISSING_PATH, { replace: true })
|
||||
}
|
||||
}, [location.pathname, navigate, onboardingQuery.error, queryClient, sessionQuery.error, setAuthUser])
|
||||
}, [location.pathname, navigate, queryClient, sessionQuery.error, setAuthUser])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionQuery.isPending || onboardingQuery.isPending) {
|
||||
if (sessionQuery.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionQuery.isError || onboardingQuery.isError) {
|
||||
if (sessionQuery.isError) {
|
||||
return
|
||||
}
|
||||
|
||||
const { pathname } = location
|
||||
const session = sessionQuery.data
|
||||
const onboardingInitialized = onboardingQuery.data?.initialized ?? false
|
||||
const isSuperAdmin = session?.user.role === 'superadmin'
|
||||
const isOnSuperAdminPage = pathname.startsWith(SUPERADMIN_ROOT_PATH)
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
if (!onboardingInitialized) {
|
||||
if (pathname !== DEFAULT_ONBOARDING_PATH) {
|
||||
navigate(DEFAULT_ONBOARDING_PATH, { replace: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (session && isSuperAdmin) {
|
||||
if (!isOnSuperAdminPage || pathname === DEFAULT_LOGIN_PATH) {
|
||||
navigate(SUPERADMIN_DEFAULT_PATH, { replace: true })
|
||||
@@ -162,31 +143,19 @@ export function usePageRedirect() {
|
||||
return
|
||||
}
|
||||
|
||||
// If not authenticated and trying to access protected route
|
||||
if (!session && !PUBLIC_PATHS.has(pathname)) {
|
||||
navigate(DEFAULT_LOGIN_PATH, { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// If authenticated but on login page, redirect to dashboard
|
||||
if (session && pathname === DEFAULT_LOGIN_PATH) {
|
||||
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
|
||||
}
|
||||
}, [
|
||||
location,
|
||||
location.pathname,
|
||||
navigate,
|
||||
onboardingQuery.data,
|
||||
onboardingQuery.isError,
|
||||
onboardingQuery.isPending,
|
||||
sessionQuery.data,
|
||||
sessionQuery.isError,
|
||||
sessionQuery.isPending,
|
||||
])
|
||||
}, [location, location.pathname, navigate, sessionQuery.data, sessionQuery.isError, sessionQuery.isPending])
|
||||
|
||||
return {
|
||||
sessionQuery,
|
||||
onboardingQuery,
|
||||
|
||||
logout,
|
||||
isAuthenticated: !!sessionQuery.data,
|
||||
user: sessionQuery.data?.user,
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface RegisterTenantAccountPayload {
|
||||
}
|
||||
|
||||
export interface RegisterTenantPayload {
|
||||
account: RegisterTenantAccountPayload
|
||||
account?: RegisterTenantAccountPayload
|
||||
tenant: {
|
||||
name: string
|
||||
slug: string | null
|
||||
@@ -18,6 +18,7 @@ export interface RegisterTenantPayload {
|
||||
key: string
|
||||
value: unknown
|
||||
}>
|
||||
useSessionAccount?: boolean
|
||||
}
|
||||
|
||||
export type RegisterTenantResult = FetchResponse<unknown>
|
||||
|
||||
@@ -74,7 +74,7 @@ export const SocialAuthButtons = memo(function SocialAuthButtons({
|
||||
|
||||
return (
|
||||
<div className={cx('space-y-3', className)}>
|
||||
{title ? <p className="text-text-tertiary text-xs uppercase tracking-wide">{title}</p> : null}
|
||||
{title ? <p className="text-text-tertiary text-xs tracking-wide uppercase">{title}</p> : null}
|
||||
<div className={containerClass}>
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
|
||||
@@ -107,7 +107,7 @@ export function SocialConnectionSettings() {
|
||||
if (hasError && errorMessage) {
|
||||
return (
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="flex items-center gap-3 text-sm text-red">
|
||||
<div className="text-red flex items-center gap-3 text-sm">
|
||||
<i className="i-mingcute-close-circle-fill text-lg" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ export function SocialConnectionSettings() {
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-base font-semibold">未配置可用的 OAuth Provider</p>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
<p className="text-text-tertiary text-sm">
|
||||
超级管理员尚未在系统设置中启用任何第三方登录方式,当前租户无法执行 OAuth 绑定。
|
||||
</p>
|
||||
</div>
|
||||
@@ -132,9 +132,9 @@ export function SocialConnectionSettings() {
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-text-tertiary">登录方式</p>
|
||||
<p className="text-text-tertiary text-sm font-semibold tracking-wide uppercase">登录方式</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">OAuth 账号绑定</h2>
|
||||
<p className="mt-2 text-sm text-text-tertiary">
|
||||
<p className="text-text-tertiary mt-2 text-sm">
|
||||
绑定后即可使用对应平台的账号快速登录后台,并同步基础资料。解除绑定不会删除原有后台账号。
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@ export function SocialConnectionSettings() {
|
||||
<i className={cx('text-2xl', provider.icon)} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-semibold leading-tight">{provider.name}</p>
|
||||
<p className="text-base leading-tight font-semibold">{provider.name}</p>
|
||||
{linkedAccount ? (
|
||||
<p className="text-text-tertiary mt-1 text-xs">
|
||||
已绑定 · {formatTimestamp(linkedAccount.createdAt)}
|
||||
|
||||
@@ -5,11 +5,19 @@ type FooterProps = {
|
||||
disableBack: boolean
|
||||
isSubmitting: boolean
|
||||
isLastStep: boolean
|
||||
disableNext?: boolean
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export const RegistrationFooter: FC<FooterProps> = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => (
|
||||
export const RegistrationFooter: FC<FooterProps> = ({
|
||||
disableBack,
|
||||
isSubmitting,
|
||||
isLastStep,
|
||||
disableNext,
|
||||
onBack,
|
||||
onNext,
|
||||
}) => (
|
||||
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div />
|
||||
<div className="flex gap-2">
|
||||
@@ -25,7 +33,15 @@ export const RegistrationFooter: FC<FooterProps> = ({ disableBack, isSubmitting,
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="primary" size="md" className="min-w-40" onClick={onNext} isLoading={isSubmitting}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="min-w-40"
|
||||
onClick={onNext}
|
||||
isLoading={isSubmitting}
|
||||
disabled={disableNext}
|
||||
>
|
||||
{isLastStep ? 'Create workspace' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -6,25 +6,26 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useAuthUserValue } from '~/atoms/auth'
|
||||
import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant'
|
||||
import type { TenantRegistrationFormState, TenantSiteFieldKey } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { getOnboardingSiteSchema } from '~/modules/onboarding/api'
|
||||
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
|
||||
import { SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/onboarding/siteSchema'
|
||||
import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types'
|
||||
import { getWelcomeSiteSchema } from '~/modules/welcome/api'
|
||||
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/welcome/siteSchema'
|
||||
import {
|
||||
coerceSiteFieldValue,
|
||||
collectSchemaFieldMap,
|
||||
createInitialSiteStateFromFieldMap,
|
||||
serializeSiteFieldValue,
|
||||
} from '~/modules/onboarding/utils'
|
||||
import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types'
|
||||
} from '~/modules/welcome/utils'
|
||||
|
||||
import { REGISTRATION_STEPS, STEP_FIELDS } from './constants'
|
||||
import { RegistrationFooter } from './RegistrationFooter'
|
||||
import { RegistrationHeader } from './RegistrationHeader'
|
||||
import { RegistrationSidebar } from './RegistrationSidebar'
|
||||
import { AdminStep } from './steps/AdminStep'
|
||||
import { LoginStep } from './steps/LoginStep'
|
||||
import { ReviewStep } from './steps/ReviewStep'
|
||||
import { SiteSettingsStep } from './steps/SiteSettingsStep'
|
||||
import { WorkspaceStep } from './steps/WorkspaceStep'
|
||||
@@ -35,6 +36,7 @@ export const RegistrationWizard: FC = () => {
|
||||
const formValues = useStore(form.store, (state) => state.values)
|
||||
const fieldMeta = useStore(form.store, (state) => state.fieldMeta)
|
||||
const { registerTenant, isLoading, error, clearError } = useRegisterTenant()
|
||||
const authUser = useAuthUserValue()
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [maxVisitedIndex, setMaxVisitedIndex] = useState(0)
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
@@ -42,12 +44,19 @@ export const RegistrationWizard: FC = () => {
|
||||
const siteDefaultsAppliedRef = useRef(false)
|
||||
|
||||
const siteSchemaQuery = useQuery({
|
||||
queryKey: ['onboarding', 'site-schema'],
|
||||
queryFn: getOnboardingSiteSchema,
|
||||
queryKey: ['welcome', 'site-schema'],
|
||||
queryFn: getWelcomeSiteSchema,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const [siteSchema, setSiteSchema] = useState<UiSchema<TenantSiteFieldKey> | null>(null)
|
||||
const advanceStep = useCallback(() => {
|
||||
setCurrentStepIndex((prev) => {
|
||||
const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1)
|
||||
setMaxVisitedIndex((visited) => Math.max(visited, nextIndex))
|
||||
return nextIndex
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const data = siteSchemaQuery.data as
|
||||
@@ -70,7 +79,10 @@ export const RegistrationWizard: FC = () => {
|
||||
}
|
||||
|
||||
const fieldMap = collectSchemaFieldMap(data.schema)
|
||||
const defaults = createInitialSiteStateFromFieldMap(fieldMap)
|
||||
const defaults = createInitialSiteStateFromFieldMap(
|
||||
fieldMap,
|
||||
DEFAULT_SITE_SETTINGS_VALUES as Record<string, SchemaFormValue>,
|
||||
)
|
||||
const presetValues = data.values ?? {}
|
||||
|
||||
let applied = false
|
||||
@@ -157,6 +169,12 @@ export const RegistrationWizard: FC = () => {
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [currentStepIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser && currentStepIndex === 0) {
|
||||
advanceStep()
|
||||
}
|
||||
}, [advanceStep, authUser, currentStepIndex])
|
||||
|
||||
const canNavigateTo = useCallback((index: number) => index <= maxVisitedIndex, [maxVisitedIndex])
|
||||
|
||||
const onFieldInteraction = useCallback(() => {
|
||||
@@ -218,6 +236,14 @@ export const RegistrationWizard: FC = () => {
|
||||
if (isLoading) return
|
||||
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
if (step.id === 'login') {
|
||||
if (!authUser) {
|
||||
toast.error('Please sign in to continue')
|
||||
return
|
||||
}
|
||||
advanceStep()
|
||||
return
|
||||
}
|
||||
if (step.id === 'site') {
|
||||
const result = siteSettingsSchema.safeParse(formValues)
|
||||
if (!result.success) {
|
||||
@@ -225,7 +251,7 @@ export const RegistrationWizard: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStepIndex(currentStepIndex + 1)
|
||||
advanceStep()
|
||||
return
|
||||
}
|
||||
const stepIsValid = await ensureStepValid(step.id)
|
||||
@@ -266,24 +292,20 @@ export const RegistrationWizard: FC = () => {
|
||||
registerTenant({
|
||||
tenantName: trimmedTenantName,
|
||||
tenantSlug: trimmedTenantSlug,
|
||||
accountName: state.values.accountName.trim(),
|
||||
email: state.values.email.trim(),
|
||||
password: state.values.password,
|
||||
settings: siteSettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStepIndex((prev) => {
|
||||
const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1)
|
||||
setMaxVisitedIndex((visited) => Math.max(visited, nextIndex))
|
||||
return nextIndex
|
||||
})
|
||||
advanceStep()
|
||||
}, [
|
||||
advanceStep,
|
||||
authUser,
|
||||
currentStepIndex,
|
||||
ensureStepValid,
|
||||
focusFirstInvalidStep,
|
||||
form,
|
||||
formValues,
|
||||
isLoading,
|
||||
onFieldInteraction,
|
||||
registerTenant,
|
||||
@@ -328,6 +350,16 @@ export const RegistrationWizard: FC = () => {
|
||||
const StepComponent = useMemo(() => {
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
switch (step.id) {
|
||||
case 'login': {
|
||||
return (
|
||||
<LoginStep
|
||||
isAuthenticated={Boolean(authUser)}
|
||||
user={authUser}
|
||||
onContinue={advanceStep}
|
||||
isContinuing={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'workspace': {
|
||||
return (
|
||||
<WorkspaceStep
|
||||
@@ -351,14 +383,12 @@ export const RegistrationWizard: FC = () => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'admin': {
|
||||
return <AdminStep form={form} isSubmitting={isLoading} onFieldInteraction={onFieldInteraction} />
|
||||
}
|
||||
case 'review': {
|
||||
return (
|
||||
<ReviewStep
|
||||
form={form}
|
||||
values={formValues}
|
||||
authUser={authUser}
|
||||
siteSchema={siteSchema}
|
||||
siteSchemaLoading={siteSchemaLoading}
|
||||
siteSchemaError={siteSchemaErrorMessage}
|
||||
@@ -373,6 +403,8 @@ export const RegistrationWizard: FC = () => {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
advanceStep,
|
||||
authUser,
|
||||
currentStepIndex,
|
||||
error,
|
||||
form,
|
||||
@@ -386,6 +418,7 @@ export const RegistrationWizard: FC = () => {
|
||||
])
|
||||
|
||||
const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1
|
||||
const disableNextButton = REGISTRATION_STEPS[currentStepIndex].id === 'login' && !authUser
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-10">
|
||||
@@ -420,6 +453,7 @@ export const RegistrationWizard: FC = () => {
|
||||
disableBack={currentStepIndex === 0}
|
||||
isSubmitting={isLoading}
|
||||
isLastStep={isLastStep}
|
||||
disableNext={disableNextButton}
|
||||
onBack={handleBack}
|
||||
onNext={() => {
|
||||
void handleNext()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
|
||||
export const REGISTRATION_STEPS = [
|
||||
{
|
||||
id: 'login',
|
||||
title: 'Connect account',
|
||||
description: 'Sign in with your identity provider to continue.',
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
title: 'Workspace details',
|
||||
@@ -11,18 +16,13 @@ export const REGISTRATION_STEPS = [
|
||||
title: 'Site information',
|
||||
description: 'Configure the public gallery branding your visitors will see.',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
title: 'Administrator account',
|
||||
description: 'Set up the primary administrator who will manage the workspace after creation.',
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Review & confirm',
|
||||
description: 'Verify everything looks right and accept the terms before provisioning the workspace.',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: 'workspace' | 'site' | 'admin' | 'review'
|
||||
id: 'login' | 'workspace' | 'site' | 'review'
|
||||
title: string
|
||||
description: string
|
||||
}>
|
||||
@@ -30,9 +30,9 @@ export const REGISTRATION_STEPS = [
|
||||
export type RegistrationStepId = (typeof REGISTRATION_STEPS)[number]['id']
|
||||
|
||||
export const STEP_FIELDS: Record<RegistrationStepId, Array<keyof TenantRegistrationFormState>> = {
|
||||
login: [],
|
||||
workspace: ['tenantName', 'tenantSlug'],
|
||||
site: [],
|
||||
admin: ['accountName', 'email', 'password', 'confirmPassword'],
|
||||
review: ['termsAccepted'],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { FormError, Input, Label } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons'
|
||||
import type { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
|
||||
import { firstErrorMessage } from '../utils'
|
||||
|
||||
type AdminStepProps = {
|
||||
form: ReturnType<typeof useRegistrationForm>
|
||||
isSubmitting: boolean
|
||||
onFieldInteraction: () => void
|
||||
}
|
||||
|
||||
export const AdminStep: FC<AdminStepProps> = ({ form, isSubmitting, onFieldInteraction }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Administrator</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
The first user becomes the workspace administrator and can invite additional members later.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<form.Field name="accountName">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Full name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="Jane Doe"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="email">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Work email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="jane@acme.studio"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="password">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="Create a strong password"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="confirmPassword">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Confirm password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="Repeat your password"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
</div>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
We recommend using a secure password manager to store credentials for critical roles like the administrator.
|
||||
</p>
|
||||
<SocialAuthButtons
|
||||
className="border border-white/5 bg-fill/40 rounded-2xl p-4"
|
||||
title="Or use single sign-on"
|
||||
requestSignUp
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Button } from '@afilmory/ui'
|
||||
import { cx } from '@afilmory/utils'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { BetterAuthUser } from '~/modules/auth/types'
|
||||
|
||||
import { SocialAuthButtons } from '../../SocialAuthButtons'
|
||||
|
||||
type LoginStepProps = {
|
||||
user: BetterAuthUser | null
|
||||
isAuthenticated: boolean
|
||||
onContinue: () => void
|
||||
isContinuing: boolean
|
||||
}
|
||||
|
||||
export const LoginStep: FC<LoginStepProps> = ({ user, isAuthenticated, onContinue, isContinuing }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Sign in to continue</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Use your organization's identity provider to create a workspace. We'll use your profile details to set
|
||||
up the initial administrator.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-fill/40 rounded-2xl border border-white/5 px-6 py-5">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Choose your provider below. After completing the sign-in flow you'll return here automatically.
|
||||
</p>
|
||||
</div>
|
||||
<SocialAuthButtons className="max-w-sm" requestSignUp layout="row" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-fill/40 rounded-2xl border border-white/5 p-6">
|
||||
<p className="text-text-secondary text-sm">You're signed in as</p>
|
||||
<div className="text-text mt-2 text-lg font-semibold">{user?.name || user?.email}</div>
|
||||
<div className="text-text-tertiary text-sm">{user?.email}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className={cx('mt-6 min-w-[200px]')}
|
||||
onClick={onContinue}
|
||||
isLoading={isContinuing}
|
||||
>
|
||||
Continue setup
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TenantSiteFieldKey,
|
||||
useRegistrationForm,
|
||||
} from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import type { BetterAuthUser } from '~/modules/auth/types'
|
||||
import type { SchemaFormValue, UiFieldNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import { collectSiteFields, firstErrorMessage } from '../utils'
|
||||
@@ -16,6 +17,7 @@ import { collectSiteFields, firstErrorMessage } from '../utils'
|
||||
type ReviewStepProps = {
|
||||
form: ReturnType<typeof useRegistrationForm>
|
||||
values: TenantRegistrationFormState
|
||||
authUser: BetterAuthUser | null
|
||||
siteSchema: UiSchema<string> | null
|
||||
siteSchemaLoading: boolean
|
||||
siteSchemaError?: string
|
||||
@@ -27,6 +29,7 @@ type ReviewStepProps = {
|
||||
export const ReviewStep: FC<ReviewStepProps> = ({
|
||||
form,
|
||||
values,
|
||||
authUser,
|
||||
siteSchema,
|
||||
siteSchemaLoading,
|
||||
siteSchemaError,
|
||||
@@ -67,40 +70,40 @@ export const ReviewStep: FC<ReviewStepProps> = ({
|
||||
Double-check the details below. You can go back to make adjustments before creating the workspace.
|
||||
</p>
|
||||
</section>
|
||||
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
|
||||
<dl className="bg-fill/40 grid gap-x-6 gap-y-4 rounded-2xl border border-white/5 p-6 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace name</dt>
|
||||
<dt className="text-text-tertiary text-xs tracking-wide uppercase">Workspace name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace slug</dt>
|
||||
<dt className="text-text-tertiary text-xs tracking-wide uppercase">Workspace slug</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantSlug || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.accountName || '—'}</dd>
|
||||
<dt className="text-text-tertiary text-xs tracking-wide uppercase">Administrator name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{authUser?.name || authUser?.email || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator email</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.email || '—'}</dd>
|
||||
<dt className="text-text-tertiary text-xs tracking-wide uppercase">Administrator email</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{authUser?.email || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-text text-base font-semibold">Site details</h3>
|
||||
{siteSchemaLoading && <div className="bg-fill/40 border border-white/5 h-32 animate-pulse rounded-2xl" />}
|
||||
{siteSchemaLoading && <div className="bg-fill/40 h-32 animate-pulse rounded-2xl border border-white/5" />}
|
||||
{!siteSchemaLoading && siteSchemaError && (
|
||||
<div className="border-red/60 bg-red/10 rounded-2xl border px-4 py-3 text-sm text-red">{siteSchemaError}</div>
|
||||
<div className="border-red/60 bg-red/10 text-red rounded-2xl border px-4 py-3 text-sm">{siteSchemaError}</div>
|
||||
)}
|
||||
{!siteSchemaLoading && !siteSchemaError && siteSchema && (
|
||||
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
|
||||
<dl className="bg-fill/40 grid gap-x-6 gap-y-4 rounded-2xl border border-white/5 p-6 md:grid-cols-2">
|
||||
{siteSummary.map(({ field, value }) => {
|
||||
const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : ''
|
||||
const isMono = field.key === 'site.accentColor'
|
||||
|
||||
return (
|
||||
<div key={field.id} className={cx(spanClass, 'min-w-0')}>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">{field.title}</dt>
|
||||
<dt className="text-text-tertiary text-xs tracking-wide uppercase">{field.title}</dt>
|
||||
<dd
|
||||
className={cx(
|
||||
'text-text mt-1 text-sm font-medium wrap-break-word',
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
TenantSiteFieldKey,
|
||||
useRegistrationForm,
|
||||
} from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { SiteStep } from '~/modules/onboarding/components/steps/SiteStep'
|
||||
import type { SchemaFormState, UiSchema } from '~/modules/schema-form/types'
|
||||
import { SiteSchemaForm } from '~/modules/welcome/components/SiteSchemaForm'
|
||||
|
||||
type SiteSettingsStepProps = {
|
||||
form: ReturnType<typeof useRegistrationForm>
|
||||
@@ -37,7 +37,7 @@ export const SiteSettingsStep: FC<SiteSettingsStepProps> = ({
|
||||
from the dashboard.
|
||||
</p>
|
||||
</section>
|
||||
<div className="bg-fill/40 border border-white/5 h-56 animate-pulse rounded-2xl" />
|
||||
<div className="bg-fill/40 h-56 animate-pulse rounded-2xl border border-white/5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,18 +51,18 @@ export const SiteSettingsStep: FC<SiteSettingsStepProps> = ({
|
||||
</p>
|
||||
</section>
|
||||
{errorMessage && (
|
||||
<div className="border-red/50 bg-red/10 rounded-xl border px-4 py-3 text-sm text-red">{errorMessage}</div>
|
||||
<div className="border-red/50 bg-red/10 text-red rounded-xl border px-4 py-3 text-sm">{errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 -mx-6 -mt-12">
|
||||
<div className="-mx-6 -mt-12 space-y-8">
|
||||
{errorMessage && (
|
||||
<div className="border-red/50 bg-red/10 rounded-xl border px-4 py-3 text-sm text-red">{errorMessage}</div>
|
||||
<div className="border-red/50 bg-red/10 text-red rounded-xl border px-4 py-3 text-sm">{errorMessage}</div>
|
||||
)}
|
||||
<SiteStep
|
||||
<SiteSchemaForm
|
||||
schema={schema}
|
||||
values={values as SchemaFormState<TenantSiteFieldKey>}
|
||||
errors={errors}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FormError, Input, Label } from '@afilmory/ui'
|
||||
import type { FC,MutableRefObject } from 'react'
|
||||
import type { FC, MutableRefObject } from 'react'
|
||||
|
||||
import type { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { slugify } from '~/modules/onboarding/utils'
|
||||
import { slugify } from '~/modules/welcome/utils'
|
||||
|
||||
import { firstErrorMessage } from '../utils'
|
||||
|
||||
|
||||
@@ -4,49 +4,16 @@ import { useState } from 'react'
|
||||
|
||||
import type { RegisterTenantPayload } from '~/modules/auth/api/registerTenant'
|
||||
import { registerTenant } from '~/modules/auth/api/registerTenant'
|
||||
import { resolveBaseDomain } from '~/modules/auth/utils/domain'
|
||||
|
||||
import type { TenantSiteFieldKey } from './useRegistrationForm'
|
||||
|
||||
interface TenantRegistrationRequest {
|
||||
tenantName: string
|
||||
tenantSlug: string
|
||||
accountName: string
|
||||
email: string
|
||||
password: string
|
||||
settings: Array<{ key: TenantSiteFieldKey; value: string }>
|
||||
}
|
||||
|
||||
const SECOND_LEVEL_PUBLIC_SUFFIXES = new Set(['ac', 'co', 'com', 'edu', 'gov', 'net', 'org'])
|
||||
|
||||
function resolveBaseDomain(hostname: string): string {
|
||||
const envValue = (import.meta.env as Record<string, unknown> | undefined)?.VITE_APP_TENANT_BASE_DOMAIN
|
||||
if (typeof envValue === 'string' && envValue.trim().length > 0) {
|
||||
return envValue.trim().replace(/^\./, '').toLowerCase()
|
||||
}
|
||||
|
||||
if (!hostname) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
||||
return 'localhost'
|
||||
}
|
||||
|
||||
const parts = hostname.split('.').filter(Boolean)
|
||||
if (parts.length <= 2) {
|
||||
return hostname
|
||||
}
|
||||
|
||||
const tld = parts.at(-1) ?? ''
|
||||
const secondLevel = parts.at(-2) ?? ''
|
||||
|
||||
if (tld.length === 2 && SECOND_LEVEL_PUBLIC_SUFFIXES.has(secondLevel) && parts.length >= 3) {
|
||||
return parts.slice(-3).join('.').toLowerCase()
|
||||
}
|
||||
|
||||
return parts.slice(-2).join('.').toLowerCase()
|
||||
}
|
||||
|
||||
function buildTenantLoginUrl(slug: string): string {
|
||||
const normalizedSlug = slug.trim().toLowerCase()
|
||||
if (!normalizedSlug) {
|
||||
@@ -80,11 +47,7 @@ export function useRegisterTenant() {
|
||||
name: data.tenantName.trim(),
|
||||
slug: data.tenantSlug.trim(),
|
||||
},
|
||||
account: {
|
||||
name: data.accountName.trim() || data.email.trim(),
|
||||
email: data.email.trim(),
|
||||
password: data.password,
|
||||
},
|
||||
useSessionAccount: true,
|
||||
}
|
||||
|
||||
if (data.settings.length > 0) {
|
||||
|
||||
@@ -2,56 +2,30 @@ import { useForm } from '@tanstack/react-form'
|
||||
import { useMemo } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/onboarding/siteSchema'
|
||||
import type { SiteFormState } from '~/modules/onboarding/types'
|
||||
import { isLikelyEmail } from '~/modules/onboarding/utils'
|
||||
import type { SchemaFormState } from '~/modules/schema-form/types'
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/welcome/siteSchema'
|
||||
|
||||
export type TenantSiteFieldKey = (typeof SITE_SETTINGS_KEYS)[number]
|
||||
type SiteFormState = SchemaFormState<TenantSiteFieldKey>
|
||||
|
||||
export type TenantRegistrationFormState = SiteFormState & {
|
||||
tenantName: string
|
||||
tenantSlug: string
|
||||
accountName: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
termsAccepted: boolean
|
||||
}
|
||||
|
||||
const REQUIRED_PASSWORD_LENGTH = 8
|
||||
|
||||
const baseRegistrationSchema = z.object({
|
||||
tenantName: z.string().min(1, { error: 'Workspace name is required' }),
|
||||
tenantSlug: z
|
||||
.string()
|
||||
.min(1, { error: 'Slug is required' })
|
||||
.regex(/^[a-z0-9-]+$/, { error: 'Use lowercase letters, numbers, and hyphen only' }),
|
||||
accountName: z.string().min(1, { error: 'Administrator name is required' }),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { error: 'Email is required' })
|
||||
.refine((value) => isLikelyEmail(value), { error: 'Enter a valid email address' }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { error: 'Password is required' })
|
||||
.min(REQUIRED_PASSWORD_LENGTH, {
|
||||
error: `Password must be at least ${REQUIRED_PASSWORD_LENGTH} characters`,
|
||||
}),
|
||||
confirmPassword: z.string().min(1, { error: 'Confirm your password' }),
|
||||
termsAccepted: z.boolean({
|
||||
error: 'You must accept the terms to continue',
|
||||
}),
|
||||
})
|
||||
|
||||
export const tenantRegistrationSchema = siteSettingsSchema.merge(baseRegistrationSchema).superRefine((data, ctx) => {
|
||||
if (data.confirmPassword !== '' && data.password !== data.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
error: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
}
|
||||
})
|
||||
export const tenantRegistrationSchema = siteSettingsSchema.merge(baseRegistrationSchema)
|
||||
|
||||
export function buildRegistrationInitialValues(
|
||||
initial?: Partial<TenantRegistrationFormState>,
|
||||
@@ -74,10 +48,6 @@ export function buildRegistrationInitialValues(
|
||||
return {
|
||||
tenantName: initial?.tenantName ?? '',
|
||||
tenantSlug: initial?.tenantSlug ?? '',
|
||||
accountName: initial?.accountName ?? '',
|
||||
email: initial?.email ?? '',
|
||||
password: initial?.password ?? '',
|
||||
confirmPassword: initial?.confirmPassword ?? '',
|
||||
termsAccepted: initial?.termsAccepted ?? false,
|
||||
...siteValues,
|
||||
}
|
||||
|
||||
60
be/apps/dashboard/src/modules/auth/utils/domain.ts
Normal file
60
be/apps/dashboard/src/modules/auth/utils/domain.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
const SECOND_LEVEL_PUBLIC_SUFFIXES = new Set(['ac', 'co', 'com', 'edu', 'gov', 'net', 'org'])
|
||||
|
||||
export function resolveBaseDomain(hostname: string): string {
|
||||
const envValue = (import.meta.env as Record<string, unknown> | undefined)?.VITE_APP_TENANT_BASE_DOMAIN
|
||||
if (typeof envValue === 'string' && envValue.trim().length > 0) {
|
||||
return envValue.trim().replace(/^\./, '').toLowerCase()
|
||||
}
|
||||
|
||||
if (!hostname) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
||||
return 'localhost'
|
||||
}
|
||||
|
||||
const parts = hostname.split('.').filter(Boolean)
|
||||
if (parts.length <= 2) {
|
||||
return hostname
|
||||
}
|
||||
|
||||
const tld = parts.at(-1) ?? ''
|
||||
const secondLevel = parts.at(-2) ?? ''
|
||||
|
||||
if (tld.length === 2 && SECOND_LEVEL_PUBLIC_SUFFIXES.has(secondLevel) && parts.length >= 3) {
|
||||
return parts.slice(-3).join('.').toLowerCase()
|
||||
}
|
||||
|
||||
return parts.slice(-2).join('.').toLowerCase()
|
||||
}
|
||||
|
||||
export function getTenantSlugFromHost(hostname: string): string | null {
|
||||
if (!hostname) {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseDomain = resolveBaseDomain(hostname)
|
||||
if (!baseDomain) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (baseDomain === 'localhost') {
|
||||
if (!hostname.endsWith('.localhost') || hostname === 'localhost') {
|
||||
return null
|
||||
}
|
||||
const candidate = hostname.slice(0, -'.localhost'.length)
|
||||
return candidate || null
|
||||
}
|
||||
|
||||
if (hostname === baseDomain) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hostname.endsWith(`.${baseDomain}`)) {
|
||||
const candidate = hostname.slice(0, -(baseDomain.length + 1))
|
||||
return candidate.includes('.') ? null : candidate || null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
import type { OnboardingSettingKey, OnboardingSiteSettingKey } from './constants'
|
||||
|
||||
export type OnboardingStatusResponse = {
|
||||
initialized: boolean
|
||||
}
|
||||
|
||||
export type OnboardingInitPayload = {
|
||||
admin: {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
}
|
||||
tenant: {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
settings?: Array<{
|
||||
key: OnboardingSettingKey | OnboardingSiteSettingKey
|
||||
value: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
export type OnboardingInitResponse = {
|
||||
ok: boolean
|
||||
adminUserId: string
|
||||
tenantId: string
|
||||
superAdminUserId: string
|
||||
}
|
||||
|
||||
export async function getOnboardingStatus() {
|
||||
return await coreApi<OnboardingStatusResponse>('/onboarding/status', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getOnboardingSiteSchema() {
|
||||
return await coreApi('/onboarding/site-schema', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export async function postOnboardingInit(payload: OnboardingInitPayload) {
|
||||
return await coreApi<OnboardingInitResponse>('/onboarding/init', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Button } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
type OnboardingFooterProps = {
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
disableBack: boolean
|
||||
isSubmitting: boolean
|
||||
isLastStep: boolean
|
||||
}
|
||||
|
||||
export const OnboardingFooter: FC<OnboardingFooterProps> = ({
|
||||
onBack,
|
||||
onNext,
|
||||
disableBack,
|
||||
isSubmitting,
|
||||
isLastStep,
|
||||
}) => (
|
||||
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
{!disableBack ? (
|
||||
<div className="text-text-tertiary text-xs">
|
||||
Need to revisit an earlier step? Use the sidebar or go back to adjust your inputs.
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{!disableBack && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="min-w-[140px]"
|
||||
onClick={onNext}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isLastStep ? 'Initialize' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { OnboardingStep } from '../constants'
|
||||
|
||||
type OnboardingHeaderProps = {
|
||||
currentStepIndex: number
|
||||
totalSteps: number
|
||||
step: OnboardingStep
|
||||
}
|
||||
|
||||
export const OnboardingHeader: FC<OnboardingHeaderProps> = ({ currentStepIndex, totalSteps, step }) => (
|
||||
<header className="p-8 pb-6">
|
||||
<div className="bg-accent/10 text-accent inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium">
|
||||
Step {currentStepIndex + 1} of {totalSteps}
|
||||
</div>
|
||||
<h1 className="text-text mt-4 text-3xl font-bold">{step.title}</h1>
|
||||
<p className="text-text-secondary mt-2 max-w-2xl text-sm">{step.description}</p>
|
||||
</header>
|
||||
)
|
||||
@@ -1,130 +0,0 @@
|
||||
import { cx } from '@afilmory/utils'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { ONBOARDING_STEPS } from '../constants'
|
||||
import { stepProgress } from '../utils'
|
||||
|
||||
type OnboardingSidebarProps = {
|
||||
currentStepIndex: number
|
||||
canNavigateTo: (index: number) => boolean
|
||||
onStepSelect: (index: number) => void
|
||||
}
|
||||
|
||||
export const OnboardingSidebar: FC<OnboardingSidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => (
|
||||
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
|
||||
<div>
|
||||
<p className="text-accent text-xs font-medium">Setup Journey</p>
|
||||
<h2 className="text-text mt-2 text-base font-semibold">Launch your photo platform</h2>
|
||||
</div>
|
||||
|
||||
{/* Timeline container */}
|
||||
<div className="relative flex-1">
|
||||
{ONBOARDING_STEPS.map((step, index) => {
|
||||
const status: 'done' | 'current' | 'pending' =
|
||||
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
|
||||
|
||||
const isLast = index === ONBOARDING_STEPS.length - 1
|
||||
const isClickable = canNavigateTo(index)
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex gap-3">
|
||||
{/* Vertical line - only show if not last item */}
|
||||
{!isLast && (
|
||||
<div className="absolute top-7 bottom-0 left-[13px] w-[1.5px]">
|
||||
{/* Completed segment */}
|
||||
{status === 'done' && <div className="bg-accent h-full w-full" />}
|
||||
{/* Current segment - gradient transition */}
|
||||
{status === 'current' && (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, var(--color-accent) 0%, var(--color-accent) 30%, color-mix(in srgb, var(--color-text) 15%, transparent) 100%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Pending segment */}
|
||||
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step node and content */}
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
'relative flex w-full items-start gap-3 pb-6 text-left transition-all duration-200',
|
||||
isClickable ? 'cursor-pointer' : 'cursor-default',
|
||||
!isClickable && 'opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
onStepSelect(index)
|
||||
}
|
||||
}}
|
||||
disabled={!isClickable}
|
||||
>
|
||||
{/* Circle node */}
|
||||
<div className="relative z-10 shrink-0 pt-0.5">
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
|
||||
// Done state
|
||||
status === 'done' && 'bg-accent text-white ring-4 ring-accent/10',
|
||||
// Current state with subtle glow
|
||||
status === 'current' && 'bg-accent text-white ring-4 ring-accent/25',
|
||||
// Pending state
|
||||
status === 'pending' && 'border-[1.5px] border-text/20 bg-background text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{status === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className={cx(
|
||||
'text-sm font-medium transition-colors duration-200',
|
||||
status === 'done' && 'text-text',
|
||||
status === 'current' && 'text-accent',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
isClickable && status !== 'current' && 'group-hover:text-text',
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
<p
|
||||
className={cx(
|
||||
'mt-0.5 text-xs transition-colors duration-200',
|
||||
status === 'done' && 'text-text-secondary',
|
||||
status === 'current' && 'text-text-secondary',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress footer */}
|
||||
<div className="pt-4">
|
||||
{/* Horizontal divider */}
|
||||
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
|
||||
<div className="text-text-tertiary mb-2 flex items-center justify-between text-xs">
|
||||
<span>Overall Progress</span>
|
||||
<span className="text-accent font-medium">{stepProgress(currentStepIndex)}%</span>
|
||||
</div>
|
||||
<div className="bg-fill-tertiary relative h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-accent absolute top-0 left-0 h-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${stepProgress(currentStepIndex)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
@@ -1,219 +0,0 @@
|
||||
import { ScrollArea } from '@afilmory/ui'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { ONBOARDING_STEPS } from '../constants'
|
||||
import { useOnboardingWizard } from '../hooks/useOnboardingWizard'
|
||||
import { LinearBorderContainer } from './LinearBorderContainer'
|
||||
import { OnboardingFooter } from './OnboardingFooter'
|
||||
import { OnboardingHeader } from './OnboardingHeader'
|
||||
import { OnboardingSidebar } from './OnboardingSidebar'
|
||||
import { ErrorState } from './states/ErrorState'
|
||||
import { InitializedState } from './states/InitializedState'
|
||||
import { LoadingState } from './states/LoadingState'
|
||||
import { AdminStep } from './steps/AdminStep'
|
||||
import { ReviewStep } from './steps/ReviewStep'
|
||||
import { SettingsStep } from './steps/SettingsStep'
|
||||
import { SiteStep } from './steps/SiteStep'
|
||||
import { TenantStep } from './steps/TenantStep'
|
||||
import { WelcomeStep } from './steps/WelcomeStep'
|
||||
|
||||
export const OnboardingWizard: FC = () => {
|
||||
const wizard = useOnboardingWizard()
|
||||
const {
|
||||
query,
|
||||
mutation,
|
||||
currentStepIndex,
|
||||
currentStep,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
jumpToStep,
|
||||
canNavigateTo,
|
||||
tenant,
|
||||
admin,
|
||||
site,
|
||||
settingsState,
|
||||
acknowledged,
|
||||
setAcknowledged,
|
||||
errors,
|
||||
updateTenantName,
|
||||
updateTenantSlug,
|
||||
updateAdminField,
|
||||
toggleSetting,
|
||||
updateSettingValue,
|
||||
updateSiteField,
|
||||
reviewSettings,
|
||||
siteSchema,
|
||||
siteSchemaLoading,
|
||||
siteSchemaError,
|
||||
} = wizard
|
||||
|
||||
// Autofocus management: focus first focusable control when step changes
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = contentRef.current
|
||||
if (!root) return
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const selector = [
|
||||
'input:not([type="hidden"]):not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
const candidates = Array.from(root.querySelectorAll<HTMLElement>(selector))
|
||||
const firstVisible = candidates.find((el) => {
|
||||
// Skip elements that are aria-hidden or not rendered
|
||||
if (el.getAttribute('aria-hidden') === 'true') return false
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return false
|
||||
// Skip disabled switches/buttons
|
||||
if ((el as HTMLInputElement).disabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
firstVisible?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [currentStepIndex, currentStep.id])
|
||||
|
||||
// Enter-to-next: advance when pressing Enter on inputs (not textarea or composing)
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key !== 'Enter') return
|
||||
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
// Avoid while IME composing
|
||||
const nativeEvent = event.nativeEvent as unknown as {
|
||||
isComposing?: boolean
|
||||
}
|
||||
if (nativeEvent?.isComposing) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
if (target.isContentEditable) return
|
||||
if (target.tagName === 'TEXTAREA') return
|
||||
|
||||
// Let buttons/links behave naturally
|
||||
if (target.tagName === 'BUTTON' || target.tagName === 'A') return
|
||||
|
||||
// Avoid toggles like checkbox/radio
|
||||
if (target.tagName === 'INPUT') {
|
||||
const { type } = target as HTMLInputElement
|
||||
if (type === 'checkbox' || type === 'radio') return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
goToNext()
|
||||
},
|
||||
[goToNext],
|
||||
)
|
||||
|
||||
if (query.isLoading) {
|
||||
return <LoadingState />
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return <ErrorState />
|
||||
}
|
||||
|
||||
if (query.data?.initialized) {
|
||||
return <InitializedState />
|
||||
}
|
||||
|
||||
if (siteSchemaLoading || !siteSchema) {
|
||||
return <LoadingState />
|
||||
}
|
||||
|
||||
if (siteSchemaError) {
|
||||
return <ErrorState />
|
||||
}
|
||||
|
||||
const stepContent: Record<typeof currentStep.id, ReactNode> = {
|
||||
welcome: <WelcomeStep />,
|
||||
tenant: (
|
||||
<TenantStep tenant={tenant} errors={errors} onNameChange={updateTenantName} onSlugChange={updateTenantSlug} />
|
||||
),
|
||||
admin: <AdminStep admin={admin} errors={errors} onChange={updateAdminField} />,
|
||||
site: <SiteStep schema={siteSchema} values={site} errors={errors} onFieldChange={updateSiteField} />,
|
||||
settings: (
|
||||
<SettingsStep
|
||||
settingsState={settingsState}
|
||||
errors={errors}
|
||||
onToggle={toggleSetting}
|
||||
onChange={updateSettingValue}
|
||||
/>
|
||||
),
|
||||
review: (
|
||||
<ReviewStep
|
||||
tenant={tenant}
|
||||
admin={admin}
|
||||
site={site}
|
||||
siteSchema={siteSchema}
|
||||
siteSchemaLoading={false}
|
||||
siteSchemaError={null}
|
||||
reviewSettings={reviewSettings}
|
||||
acknowledged={acknowledged}
|
||||
errors={errors}
|
||||
onAcknowledgeChange={setAcknowledged}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-10">
|
||||
<LinearBorderContainer className="bg-background-tertiary h-[85vh] w-full max-w-7xl">
|
||||
<div className="grid h-full lg:grid-cols-[280px_1fr]">
|
||||
{/* Sidebar */}
|
||||
<div className="relative h-full">
|
||||
{/* Vertical divider with gradient that fades at top/bottom */}
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
|
||||
<OnboardingSidebar
|
||||
currentStepIndex={currentStepIndex}
|
||||
canNavigateTo={canNavigateTo}
|
||||
onStepSelect={jumpToStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content with fixed height and scrollable area */}
|
||||
<main className="flex h-full w-[800px] flex-col">
|
||||
{/* Fixed header */}
|
||||
<div className="shrink-0">
|
||||
<OnboardingHeader
|
||||
currentStepIndex={currentStepIndex}
|
||||
totalSteps={ONBOARDING_STEPS.length}
|
||||
step={currentStep}
|
||||
/>
|
||||
{/* Horizontal divider */}
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="relative flex h-0 flex-1">
|
||||
<ScrollArea rootClassName="absolute! inset-0 h-full w-full">
|
||||
<section ref={contentRef} className="p-12" onKeyDown={handleKeyDown}>
|
||||
{stepContent[currentStep.id]}
|
||||
</section>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0">
|
||||
{/* Horizontal divider */}
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<OnboardingFooter
|
||||
onBack={goToPrevious}
|
||||
onNext={goToNext}
|
||||
disableBack={currentStepIndex === 0}
|
||||
isSubmitting={mutation.isPending}
|
||||
isLastStep={currentStepIndex === ONBOARDING_STEPS.length - 1}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { LinearBorderContainer } from '../LinearBorderContainer'
|
||||
|
||||
export const ErrorState: FC = () => (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<LinearBorderContainer tint="color-mix(in srgb, var(--color-red) 50%, transparent)">
|
||||
<div className="bg-fill-secondary/60 w-full max-w-lg p-8 text-center">
|
||||
<i className="i-mingcute-alert-fill text-red mb-3 text-3xl" />
|
||||
<h1 className="text-text mb-2 text-2xl font-semibold">Unable to connect</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
The dashboard could not reach the core service. Ensure the backend is running and refresh the page.
|
||||
</p>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
</div>
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { LinearBorderContainer } from '../LinearBorderContainer'
|
||||
|
||||
export const InitializedState: FC = () => (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<LinearBorderContainer tint="color-mix(in srgb, var(--color-yellow) 50%, transparent)">
|
||||
<div className="bg-fill-secondary/60 w-full max-w-lg p-8 text-center">
|
||||
<i className="i-mingcute-shield-user-fill text-accent mt-0.5" />
|
||||
<h1 className="text-text mb-2 text-2xl font-semibold">Afilmory Control Center is ready</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Your workspace has already been provisioned. Sign in with an existing administrator account or invite new
|
||||
members from the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
</div>
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { LinearBorderContainer } from '../LinearBorderContainer'
|
||||
|
||||
export const LoadingState: FC = () => (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<LinearBorderContainer tint="color-mix(in srgb, var(--color-accent) 50%, transparent)">
|
||||
<div className="bg-fill-secondary/60 w-full max-w-lg p-8 text-center">
|
||||
<i className="i-mingcute-loading-3-fill text-accent animate-spin" />
|
||||
<h1 className="text-text mb-2 text-2xl font-semibold">Preparing onboarding experience</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
The dashboard is preparing the onboarding experience. Please wait a moment.
|
||||
</p>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
</div>
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
import { FormError, Input, Label } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { AdminFormState, OnboardingErrors } from '../../types'
|
||||
|
||||
type AdminStepProps = {
|
||||
admin: AdminFormState
|
||||
errors: OnboardingErrors
|
||||
onChange: <Field extends keyof AdminFormState>(field: Field, value: AdminFormState[Field]) => void
|
||||
}
|
||||
|
||||
export const AdminStep: FC<AdminStepProps> = ({ admin, errors, onChange }) => (
|
||||
<form className="space-y-6" onSubmit={(event) => event.preventDefault()}>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-name">Administrator name</Label>
|
||||
<Input
|
||||
id="admin-name"
|
||||
value={admin.name}
|
||||
onInput={(event) => onChange('name', event.currentTarget.value)}
|
||||
placeholder="Studio Admin"
|
||||
autoComplete="name"
|
||||
error={!!errors['admin.name']}
|
||||
/>
|
||||
<FormError>{errors['admin.name']}</FormError>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-email">Administrator email</Label>
|
||||
<Input
|
||||
id="admin-email"
|
||||
value={admin.email}
|
||||
onInput={(event) => onChange('email', event.currentTarget.value)}
|
||||
placeholder="admin@afilmory.app"
|
||||
autoComplete="email"
|
||||
error={!!errors['admin.email']}
|
||||
/>
|
||||
<FormError>{errors['admin.email']}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-password">Password</Label>
|
||||
<Input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
value={admin.password}
|
||||
onInput={(event) => onChange('password', event.currentTarget.value)}
|
||||
placeholder="Minimum 8 characters"
|
||||
autoComplete="new-password"
|
||||
error={!!errors['admin.password']}
|
||||
/>
|
||||
<FormError>{errors['admin.password']}</FormError>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-confirm">Confirm password</Label>
|
||||
<Input
|
||||
id="admin-confirm"
|
||||
type="password"
|
||||
value={admin.confirmPassword}
|
||||
onInput={(event) => onChange('confirmPassword', event.currentTarget.value)}
|
||||
placeholder="Repeat password"
|
||||
autoComplete="new-password"
|
||||
error={!!errors['admin.confirmPassword']}
|
||||
/>
|
||||
<FormError>{errors['admin.confirmPassword']}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-text-tertiary text-xs">
|
||||
After onboarding completes a global super administrator will also be generated. Those credentials are written to
|
||||
the backend logs for security reasons.
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
@@ -1,178 +0,0 @@
|
||||
import { Checkbox } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { SchemaFormValue, UiFieldNode, UiNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSiteSettingKey, SettingFieldDefinition } from '../../constants'
|
||||
import type { AdminFormState, OnboardingErrors, SiteFormState, TenantFormState } from '../../types'
|
||||
import { maskSecret } from '../../utils'
|
||||
|
||||
export type ReviewSettingEntry = {
|
||||
definition: SettingFieldDefinition
|
||||
value: string
|
||||
}
|
||||
|
||||
type ReviewStepProps = {
|
||||
tenant: TenantFormState
|
||||
admin: AdminFormState
|
||||
site: SiteFormState
|
||||
siteSchema: UiSchema<OnboardingSiteSettingKey>
|
||||
siteSchemaLoading?: boolean
|
||||
siteSchemaError?: string | null
|
||||
reviewSettings: ReviewSettingEntry[]
|
||||
acknowledged: boolean
|
||||
errors: OnboardingErrors
|
||||
onAcknowledgeChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const optionalSiteValue = (value: SchemaFormValue | undefined) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Enabled' : 'Disabled'
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === 'true' || lowered === 'false') {
|
||||
return lowered === 'true' ? 'Enabled' : 'Disabled'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function collectSiteFields(
|
||||
nodes: ReadonlyArray<UiNode<OnboardingSiteSettingKey>>,
|
||||
): Array<UiFieldNode<OnboardingSiteSettingKey>> {
|
||||
const fields: Array<UiFieldNode<OnboardingSiteSettingKey>> = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
fields.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
fields.push(...collectSiteFields(node.children))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
export const ReviewStep: FC<ReviewStepProps> = ({
|
||||
tenant,
|
||||
admin,
|
||||
site,
|
||||
siteSchema,
|
||||
siteSchemaLoading = false,
|
||||
siteSchemaError = null,
|
||||
reviewSettings,
|
||||
acknowledged,
|
||||
errors,
|
||||
onAcknowledgeChange,
|
||||
}) => (
|
||||
<div className="space-y-6">
|
||||
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
|
||||
<h3 className="text-text mb-4 text-sm font-semibold">Tenant summary</h3>
|
||||
<dl className="text-text-secondary grid gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-text font-semibold">Name</dt>
|
||||
<dd className="mt-1">{tenant.name || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text font-semibold">Slug</dt>
|
||||
<dd className="mt-1">{tenant.slug || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
|
||||
<h3 className="text-text mb-4 text-sm font-semibold">Administrator</h3>
|
||||
<dl className="text-text-secondary grid gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-text font-semibold">Name</dt>
|
||||
<dd className="mt-1">{admin.name || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text font-semibold">Email</dt>
|
||||
<dd className="mt-1">{admin.email || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text font-semibold">Password</dt>
|
||||
<dd className="mt-1">{maskSecret(admin.password)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
|
||||
<h3 className="text-text mb-4 text-sm font-semibold">Site information</h3>
|
||||
{siteSchemaLoading && <div className="bg-fill/60 border border-white/5 h-24 animate-pulse rounded-xl" />}
|
||||
{!siteSchemaLoading && siteSchemaError && (
|
||||
<div className="border-red/60 bg-red/10 mt-2 rounded-xl border px-4 py-3 text-sm text-red">
|
||||
{siteSchemaError}
|
||||
</div>
|
||||
)}
|
||||
{!siteSchemaLoading && !siteSchemaError && (
|
||||
<dl className="text-text-secondary grid gap-4 text-sm md:grid-cols-2">
|
||||
{collectSiteFields(siteSchema.sections).map((field) => {
|
||||
const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : ''
|
||||
return (
|
||||
<div key={field.id} className={`${spanClass} min-w-0`}>
|
||||
<dt className="text-text font-semibold">{field.title}</dt>
|
||||
<dd className="mt-1 leading-relaxed break-words">{optionalSiteValue(site[field.key])}</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
|
||||
<h3 className="text-text mb-4 text-sm font-semibold">Enabled integrations</h3>
|
||||
{reviewSettings.length === 0 ? (
|
||||
<p className="text-text-tertiary text-sm">
|
||||
No integrations configured. You can enable OAuth providers, AI services, or maps later from the settings
|
||||
panel.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{reviewSettings.map(({ definition, value }) => (
|
||||
<li key={definition.key} className="border-fill-tertiary bg-background rounded-lg border px-4 py-3">
|
||||
<p className="text-text text-sm font-medium">{definition.label}</p>
|
||||
<p className="text-text-tertiary mt-1">{definition.sensitive ? maskSecret(value) : value}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-orange/40 bg-orange/5 rounded-lg border p-6">
|
||||
<h3 className="text-orange mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
<i className="i-mingcute-alert-fill" />
|
||||
Important
|
||||
</h3>
|
||||
<p className="text-orange/90 text-sm leading-relaxed">
|
||||
Once you click initialize, the application becomes locked to this initial administrator. The core service will
|
||||
print super administrator credentials to stdout exactly once.
|
||||
</p>
|
||||
<label className="text-text mt-4 flex items-start gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onCheckedChange={(checked) => onAcknowledgeChange(Boolean(checked))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
I have noted the super administrator credentials will appear in the backend logs and understand this action
|
||||
cannot be repeated.
|
||||
</span>
|
||||
</label>
|
||||
{errors['review.ack'] && <p className="text-red mt-2 text-xs">{errors['review.ack']}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleIcon,
|
||||
CollapsibleTrigger,
|
||||
FormError,
|
||||
Input,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@afilmory/ui'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { OnboardingSettingKey } from '../../constants'
|
||||
import { ONBOARDING_SETTING_SECTIONS } from '../../constants'
|
||||
import type { OnboardingErrors, SettingFormState } from '../../types'
|
||||
|
||||
type SettingsStepProps = {
|
||||
settingsState: SettingFormState
|
||||
errors: OnboardingErrors
|
||||
onToggle: (key: OnboardingSettingKey, enabled: boolean) => void
|
||||
onChange: (key: OnboardingSettingKey, value: string) => void
|
||||
}
|
||||
|
||||
export const SettingsStep: FC<SettingsStepProps> = ({ settingsState, errors, onToggle, onChange }) => {
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
() => new Set([ONBOARDING_SETTING_SECTIONS[0]?.id]),
|
||||
)
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sectionId)) {
|
||||
next.delete(sectionId)
|
||||
} else {
|
||||
next.add(sectionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-m-10 space-y-3">
|
||||
{ONBOARDING_SETTING_SECTIONS.map((section) => {
|
||||
const isOpen = expandedSections.has(section.id)
|
||||
const enabledCount = section.fields.filter((field) => settingsState[field.key].enabled).length
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={section.id}
|
||||
open={isOpen}
|
||||
onOpenChange={() => toggleSection(section.id)}
|
||||
className="border-fill-tertiary bg-background rounded-lg border transition-all duration-200"
|
||||
>
|
||||
<CollapsibleTrigger className={'hover:bg-fill/30 px-6 py-4'}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-text text-sm font-semibold">{section.title}</h3>
|
||||
{enabledCount > 0 && (
|
||||
<span className="bg-accent/10 text-accent rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
{enabledCount} enabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-tertiary mt-1 text-sm">{section.description}</p>
|
||||
</div>
|
||||
<CollapsibleIcon className="text-text-tertiary ml-4" />
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-fill-tertiary space-y-3 border-t p-6 pt-4">
|
||||
{section.fields.map((field) => {
|
||||
const state = settingsState[field.key]
|
||||
const errorKey = `settings.${field.key}`
|
||||
const hasError = Boolean(errors[errorKey])
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className="border-fill-tertiary bg-fill rounded-lg border p-5 transition-all duration-200"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor={`switch-${field.key}`}
|
||||
className="text-text cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<p className="text-text-tertiary mt-1 text-sm">{field.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`switch-${field.key}`}
|
||||
checked={state.enabled}
|
||||
onCheckedChange={(checked) => onToggle(field.key, checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.enabled && (
|
||||
<m.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 space-y-2">
|
||||
{field.multiline ? (
|
||||
<Textarea
|
||||
value={state.value}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
rows={3}
|
||||
placeholder={field.placeholder}
|
||||
error={hasError}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.sensitive ? 'password' : 'text'}
|
||||
value={state.value}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
error={hasError}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
<FormError>{errors[errorKey]}</FormError>
|
||||
{field.helper && <p className="text-text-tertiary text-[11px]">{field.helper}</p>}
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { SchemaFormRendererUncontrolled } from '~/modules/schema-form/SchemaFormRenderer'
|
||||
import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSiteSettingKey } from '../../constants'
|
||||
import type { SiteFormState } from '../../types'
|
||||
|
||||
type SiteStepProps = {
|
||||
schema: UiSchema<OnboardingSiteSettingKey>
|
||||
values: SiteFormState
|
||||
errors: Record<string, string>
|
||||
onFieldChange: (key: OnboardingSiteSettingKey, value: string | boolean) => void
|
||||
}
|
||||
|
||||
export const SiteStep: FC<SiteStepProps> = ({ schema, values, errors, onFieldChange }) => {
|
||||
const schemaWithErrors = useMemo(() => {
|
||||
return {
|
||||
...schema,
|
||||
sections: schema.sections.map((section) => ({
|
||||
...section,
|
||||
children: section.children.map((child: any) => {
|
||||
if (child.type !== 'field') {
|
||||
return child
|
||||
}
|
||||
const error = errors[child.key]
|
||||
return {
|
||||
...child,
|
||||
helperText: error ?? child.helperText ?? null,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}
|
||||
}, [errors, schema])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SchemaFormRendererUncontrolled
|
||||
initialValues={values}
|
||||
schema={schemaWithErrors}
|
||||
onChange={(key: OnboardingSiteSettingKey, value: SchemaFormValue) => {
|
||||
onFieldChange(key, typeof value === 'boolean' ? value : value == null ? '' : String(value))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { FormError, Input, Label } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { OnboardingErrors, TenantFormState } from '../../types'
|
||||
|
||||
type TenantStepProps = {
|
||||
tenant: TenantFormState
|
||||
errors: OnboardingErrors
|
||||
onNameChange: (value: string) => void
|
||||
onSlugChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const TenantStep: FC<TenantStepProps> = ({ tenant, errors, onNameChange, onSlugChange }) => (
|
||||
<form className="space-y-6" onSubmit={(event) => event.preventDefault()}>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Workspace name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={tenant.name}
|
||||
onInput={(event) => onNameChange(event.currentTarget.value)}
|
||||
placeholder="Afilmory Studio"
|
||||
error={!!errors['tenant.name']}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<FormError>{errors['tenant.name']}</FormError>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Tenant slug</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
value={tenant.slug}
|
||||
onInput={(event) => onSlugChange(event.currentTarget.value)}
|
||||
placeholder="afilmory"
|
||||
error={!!errors['tenant.slug']}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<FormError>{errors['tenant.slug']}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
export const WelcomeStep: FC = () => (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-text mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<i className="i-mingcute-compass-2-fill text-accent" />
|
||||
What happens next
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm leading-relaxed">
|
||||
We will create your first tenant, provision an administrator, and bootstrap super administrator access for
|
||||
emergency management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-text mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<i className="i-mingcute-shield-fill text-accent" />
|
||||
Requirements
|
||||
</h3>
|
||||
<ul className="text-text-secondary space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="i-mingcute-check-line text-accent mt-0.5 shrink-0" />
|
||||
<span>Ensure the core service can access email providers or authentication callbacks if configured.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="i-mingcute-check-line text-accent mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Keep the terminal open to capture the super administrator credentials printed after initialization.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<i className="i-mingcute-check-line text-accent mt-0.5 shrink-0" />
|
||||
<span>Prepare OAuth credentials or continue without them; you can configure integrations later.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-text mb-4 text-sm font-semibold">What we will collect</h3>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-text text-sm font-semibold">Tenant profile</p>
|
||||
<p className="text-text-secondary mt-1.5 text-sm leading-relaxed">
|
||||
Workspace name, slug, and optional domain mapping.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text text-sm font-semibold">Admin account</p>
|
||||
<p className="text-text-secondary mt-1.5 text-sm leading-relaxed">
|
||||
Email, name, and secure password for the first administrator.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text text-sm font-semibold">Integrations</p>
|
||||
<p className="text-text-secondary mt-1.5 text-sm leading-relaxed">
|
||||
Optional OAuth, AI, and map provider credentials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,128 +0,0 @@
|
||||
export type OnboardingSettingKey =
|
||||
| 'ai.openai.apiKey'
|
||||
| 'ai.openai.baseUrl'
|
||||
| 'ai.embedding.model'
|
||||
| 'http.cors.allowedOrigins'
|
||||
| 'services.amap.apiKey'
|
||||
|
||||
export type OnboardingSiteSettingKey = 'site.name' | 'site.title' | 'site.description'
|
||||
|
||||
export type SettingFieldDefinition = {
|
||||
key: OnboardingSettingKey
|
||||
label: string
|
||||
description: string
|
||||
placeholder?: string
|
||||
helper?: string
|
||||
sensitive?: boolean
|
||||
multiline?: boolean
|
||||
}
|
||||
|
||||
export type SettingSectionDefinition = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
fields: SettingFieldDefinition[]
|
||||
}
|
||||
|
||||
export const ONBOARDING_SETTING_SECTIONS: SettingSectionDefinition[] = [
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI & Embeddings',
|
||||
description:
|
||||
'Optional integrations for AI powered features. Provide your OpenAI credentials and preferred embedding model.',
|
||||
fields: [
|
||||
{
|
||||
key: 'ai.openai.apiKey',
|
||||
label: 'OpenAI API Key',
|
||||
description: 'Used for generating captions, titles, and AI assistance across the platform.',
|
||||
placeholder: 'sk-proj-xxxxxxxxxxxxxxxx',
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
key: 'ai.openai.baseUrl',
|
||||
label: 'OpenAI Base URL',
|
||||
description: 'Override the default api.openai.com endpoint if you proxy requests.',
|
||||
placeholder: 'https://api.openai.com/v1',
|
||||
},
|
||||
{
|
||||
key: 'ai.embedding.model',
|
||||
label: 'Embedding Model',
|
||||
description: 'Model identifier to compute embeddings for search and semantic features.',
|
||||
placeholder: 'text-embedding-3-large',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'map',
|
||||
title: 'Map Services',
|
||||
description: 'Connect Gaode (Amap) maps to unlock geolocation previews for your photos.',
|
||||
fields: [
|
||||
{
|
||||
key: 'services.amap.apiKey',
|
||||
label: 'Gaode (Amap) API Key',
|
||||
description: 'Required to render photo locations on the dashboard.',
|
||||
placeholder: 'your-amap-api-key',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Network & CORS',
|
||||
description: 'Restrict which origins can access the backend APIs.',
|
||||
fields: [
|
||||
{
|
||||
key: 'http.cors.allowedOrigins',
|
||||
label: 'Allowed Origins',
|
||||
description: 'Comma separated list of origins. Example: https://dashboard.afilmory.com, https://afilmory.app',
|
||||
placeholder: 'https://dashboard.afilmory.com, https://afilmory.app',
|
||||
helper: 'Leave empty to keep the default wildcard policy during setup.',
|
||||
multiline: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const ONBOARDING_TOTAL_STEPS = 6 as const
|
||||
export const ONBOARDING_STEP_ORDER = ['welcome', 'tenant', 'site', 'admin', 'settings', 'review'] as const
|
||||
|
||||
export type OnboardingStepId = (typeof ONBOARDING_STEP_ORDER)[number]
|
||||
|
||||
export type OnboardingStep = {
|
||||
id: OnboardingStepId
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Welcome',
|
||||
description: 'Verify environment and prepare initialization.',
|
||||
},
|
||||
{
|
||||
id: 'tenant',
|
||||
title: 'Tenant Profile',
|
||||
description: 'Name your workspace and choose a slug.',
|
||||
},
|
||||
{
|
||||
id: 'site',
|
||||
title: 'Site Branding',
|
||||
description: 'Set the public gallery information shown to your visitors.',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
title: 'Administrator',
|
||||
description: 'Create the first tenant admin account.',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Platform Settings',
|
||||
description: 'Set optional integration keys before launch.',
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Review & Launch',
|
||||
description: 'Confirm details and finalize initialization.',
|
||||
},
|
||||
]
|
||||
@@ -1,426 +0,0 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { FetchError } from 'ofetch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { UiFieldNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingInitPayload } from '../api'
|
||||
import { getOnboardingSiteSchema, getOnboardingStatus, postOnboardingInit } from '../api'
|
||||
import type { OnboardingSettingKey, OnboardingSiteSettingKey, OnboardingStepId } from '../constants'
|
||||
import { ONBOARDING_STEPS } from '../constants'
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '../siteSchema'
|
||||
import type { AdminFormState, OnboardingErrors, SettingFormState, SiteFormState, TenantFormState } from '../types'
|
||||
import {
|
||||
coerceSiteFieldValue,
|
||||
collectSchemaFieldMap,
|
||||
createInitialSettingsState,
|
||||
createInitialSiteStateFromFieldMap,
|
||||
getFieldByKey,
|
||||
isLikelyEmail,
|
||||
maskSecret,
|
||||
serializeSiteFieldValue,
|
||||
slugify,
|
||||
} from '../utils'
|
||||
|
||||
const INITIAL_STEP_INDEX = 0
|
||||
|
||||
const toStringValue = (value: unknown) => (value == null ? '' : String(value))
|
||||
|
||||
const trimmedNonEmpty = (message: string) => z.preprocess(toStringValue, z.string().trim().min(1, { message }))
|
||||
|
||||
const slugSchema = z.preprocess(
|
||||
toStringValue,
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: 'Slug is required' })
|
||||
.regex(/^[a-z0-9-]+$/, { message: 'Only lowercase letters, numbers, and hyphen are allowed' }),
|
||||
)
|
||||
|
||||
const passwordSchema = z.preprocess(
|
||||
toStringValue,
|
||||
z.string().min(1, { message: 'Password is required' }).min(8, { message: 'Password must be at least 8 characters' }),
|
||||
)
|
||||
|
||||
const confirmPasswordSchema = z.preprocess(
|
||||
toStringValue,
|
||||
z.string().min(1, { message: 'Confirm the password to continue' }),
|
||||
)
|
||||
|
||||
const tenantSchema = z.object({
|
||||
name: trimmedNonEmpty('Workspace name is required'),
|
||||
slug: slugSchema,
|
||||
})
|
||||
|
||||
const adminSchema = z
|
||||
.object({
|
||||
name: trimmedNonEmpty('Administrator name is required').refine((value) => !/^root$/i.test(value), {
|
||||
message: 'The name "root" is reserved',
|
||||
}),
|
||||
email: trimmedNonEmpty('Email is required').refine((value) => isLikelyEmail(value), {
|
||||
message: 'Enter a valid email address',
|
||||
}),
|
||||
password: passwordSchema,
|
||||
confirmPassword: confirmPasswordSchema,
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export function useOnboardingWizard() {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(INITIAL_STEP_INDEX)
|
||||
const [tenant, setTenant] = useState<TenantFormState>({
|
||||
name: '',
|
||||
slug: '',
|
||||
})
|
||||
const [slugLocked, setSlugLocked] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminFormState>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [settingsState, setSettingsState] = useState<SettingFormState>(createInitialSettingsState)
|
||||
const [site, setSite] = useState<SiteFormState>(() => ({ ...DEFAULT_SITE_SETTINGS_VALUES }))
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [errors, setErrors] = useState<OnboardingErrors>({})
|
||||
const [siteDefaultsApplied, setSiteDefaultsApplied] = useState(false)
|
||||
|
||||
const currentStep = ONBOARDING_STEPS[currentStepIndex] ?? ONBOARDING_STEPS[INITIAL_STEP_INDEX]
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['onboarding', 'status'],
|
||||
queryFn: getOnboardingStatus,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const siteSchemaQuery = useQuery({
|
||||
queryKey: ['onboarding', 'site-schema'],
|
||||
queryFn: getOnboardingSiteSchema,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const siteSchemaData = siteSchemaQuery.data as
|
||||
| {
|
||||
schema?: UiSchema<OnboardingSiteSettingKey>
|
||||
values?: Partial<Record<OnboardingSiteSettingKey, unknown>>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const siteSchema = siteSchemaData?.schema ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteSchema || siteDefaultsApplied) {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldMap = collectSchemaFieldMap(siteSchema)
|
||||
const defaults = createInitialSiteStateFromFieldMap(fieldMap)
|
||||
const values = siteSchemaData?.values ?? {}
|
||||
const next: SiteFormState = { ...DEFAULT_SITE_SETTINGS_VALUES, ...defaults }
|
||||
|
||||
for (const [key, field] of fieldMap) {
|
||||
const coerced = coerceSiteFieldValue(field, values[key])
|
||||
if (coerced !== undefined) {
|
||||
next[key] = coerced
|
||||
}
|
||||
}
|
||||
|
||||
setSite(next)
|
||||
setSiteDefaultsApplied(true)
|
||||
}, [siteDefaultsApplied, siteSchema, siteSchemaData?.values])
|
||||
|
||||
const siteSchemaLoading = siteSchemaQuery.isLoading && !siteSchema
|
||||
const siteSchemaError = siteSchemaQuery.isError
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: OnboardingInitPayload) => postOnboardingInit(payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Initialization completed', {
|
||||
description:
|
||||
'Super administrator credentials were printed to the core service logs. Store them securely before closing the terminal.',
|
||||
})
|
||||
void query.refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof FetchError) {
|
||||
const message =
|
||||
typeof error.data === 'object' && error.data && 'message' in error.data
|
||||
? String(error.data.message)
|
||||
: 'Backend rejected the initialization request.'
|
||||
toast.error('Initialization failed', { description: message })
|
||||
} else {
|
||||
toast.error('Initialization failed', {
|
||||
description: 'Unexpected error occurred. Please retry or inspect the logs.',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const setFieldError = (key: string, reason: string | null) => {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
if (!reason) {
|
||||
delete next[key]
|
||||
} else {
|
||||
next[key] = reason
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const validateTenant = () => {
|
||||
const result = tenantSchema.safeParse(tenant)
|
||||
|
||||
if (result.success) {
|
||||
setFieldError('tenant.name', null)
|
||||
setFieldError('tenant.slug', null)
|
||||
return true
|
||||
}
|
||||
|
||||
const { fieldErrors } = result.error.flatten((issue) => issue.message)
|
||||
setFieldError('tenant.name', fieldErrors.name?.[0] ?? null)
|
||||
setFieldError('tenant.slug', fieldErrors.slug?.[0] ?? null)
|
||||
return false
|
||||
}
|
||||
|
||||
const validateAdmin = () => {
|
||||
const result = adminSchema.safeParse(admin)
|
||||
|
||||
if (result.success) {
|
||||
setFieldError('admin.name', null)
|
||||
setFieldError('admin.email', null)
|
||||
setFieldError('admin.password', null)
|
||||
setFieldError('admin.confirmPassword', null)
|
||||
return true
|
||||
}
|
||||
|
||||
const { fieldErrors } = result.error.flatten((issue) => issue.message)
|
||||
setFieldError('admin.name', fieldErrors.name?.[0] ?? null)
|
||||
setFieldError('admin.email', fieldErrors.email?.[0] ?? null)
|
||||
setFieldError('admin.password', fieldErrors.password?.[0] ?? null)
|
||||
setFieldError('admin.confirmPassword', fieldErrors.confirmPassword?.[0] ?? null)
|
||||
return false
|
||||
}
|
||||
|
||||
const validateSite = () => {
|
||||
const candidate: Record<string, unknown> = {}
|
||||
for (const key of SITE_SETTINGS_KEYS) {
|
||||
candidate[key] = site[key] ?? DEFAULT_SITE_SETTINGS_VALUES[key]
|
||||
}
|
||||
|
||||
const result = siteSettingsSchema.safeParse(candidate)
|
||||
const fieldErrors: Record<string, string> = {}
|
||||
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const pathKey = issue.path[0]
|
||||
if (typeof pathKey === 'string' && !(pathKey in fieldErrors)) {
|
||||
fieldErrors[pathKey] = issue.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of SITE_SETTINGS_KEYS) {
|
||||
setFieldError(key, fieldErrors[key] ?? null)
|
||||
}
|
||||
|
||||
return result.success
|
||||
}
|
||||
|
||||
const validateSettings = () => {
|
||||
let valid = true
|
||||
for (const [key, entry] of Object.entries(settingsState) as Array<
|
||||
[OnboardingSettingKey, SettingFormState[OnboardingSettingKey]]
|
||||
>) {
|
||||
if (!entry.enabled) {
|
||||
setFieldError(`settings.${key}`, null)
|
||||
continue
|
||||
}
|
||||
if (!entry.value.trim()) {
|
||||
setFieldError(`settings.${key}`, 'Value is required when the setting is enabled')
|
||||
valid = false
|
||||
} else {
|
||||
setFieldError(`settings.${key}`, null)
|
||||
}
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
const validateAcknowledgement = () => {
|
||||
if (!acknowledged) {
|
||||
setFieldError('review.ack', 'Please confirm you saved the super administrator credentials before continuing')
|
||||
return false
|
||||
}
|
||||
setFieldError('review.ack', null)
|
||||
return true
|
||||
}
|
||||
|
||||
const validators: Partial<Record<OnboardingStepId, () => boolean>> = {
|
||||
welcome: () => true,
|
||||
tenant: validateTenant,
|
||||
site: validateSite,
|
||||
admin: validateAdmin,
|
||||
settings: validateSettings,
|
||||
review: validateAcknowledgement,
|
||||
}
|
||||
|
||||
const submitInitialization = () => {
|
||||
const payload: OnboardingInitPayload = {
|
||||
tenant: {
|
||||
name: tenant.name.trim(),
|
||||
slug: tenant.slug.trim(),
|
||||
},
|
||||
admin: {
|
||||
name: admin.name.trim(),
|
||||
email: admin.email.trim(),
|
||||
password: admin.password,
|
||||
},
|
||||
}
|
||||
|
||||
const settingEntries = Object.entries(settingsState)
|
||||
.filter(([, entry]) => entry.enabled && entry.value.trim())
|
||||
.map(([key, entry]) => ({
|
||||
key: key as OnboardingSettingKey,
|
||||
value: entry.value.trim(),
|
||||
}))
|
||||
|
||||
const fieldMap = siteSchema
|
||||
? collectSchemaFieldMap(siteSchema)
|
||||
: new Map<OnboardingSiteSettingKey, UiFieldNode<OnboardingSiteSettingKey>>()
|
||||
const siteEntries = Array.from(fieldMap.entries()).map(([key, field]) => ({
|
||||
key,
|
||||
value: serializeSiteFieldValue(field, site[key]),
|
||||
}))
|
||||
|
||||
const combined = [...settingEntries, ...siteEntries]
|
||||
|
||||
if (combined.length > 0) {
|
||||
payload.settings = combined as Array<{ key: OnboardingSettingKey | OnboardingSiteSettingKey; value: string }>
|
||||
}
|
||||
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
const validator = validators[currentStep.id]
|
||||
if (validator && !validator()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStepIndex === ONBOARDING_STEPS.length - 1) {
|
||||
submitInitialization()
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, ONBOARDING_STEPS.length - 1))
|
||||
}
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentStepIndex((prev) => Math.max(prev - 1, 0))
|
||||
}
|
||||
|
||||
const jumpToStep = (index: number) => {
|
||||
if (index <= currentStepIndex) {
|
||||
setCurrentStepIndex(index)
|
||||
}
|
||||
}
|
||||
|
||||
const updateTenantName = (value: string) => {
|
||||
setTenant((prev) => {
|
||||
if (!slugLocked) {
|
||||
return { ...prev, name: value, slug: slugify(value) }
|
||||
}
|
||||
return { ...prev, name: value }
|
||||
})
|
||||
setFieldError('tenant.name', null)
|
||||
}
|
||||
|
||||
const updateTenantSlug = (value: string) => {
|
||||
setSlugLocked(true)
|
||||
setTenant((prev) => ({ ...prev, slug: value }))
|
||||
setFieldError('tenant.slug', null)
|
||||
}
|
||||
|
||||
const updateAdminField = (field: keyof AdminFormState, value: string) => {
|
||||
setAdmin((prev) => ({ ...prev, [field]: value }))
|
||||
setFieldError(`admin.${field}`, null)
|
||||
}
|
||||
|
||||
const toggleSetting = (key: OnboardingSettingKey, enabled: boolean) => {
|
||||
setSettingsState((prev) => {
|
||||
const next = { ...prev, [key]: { ...prev[key], enabled } }
|
||||
if (!enabled) {
|
||||
next[key].value = ''
|
||||
setFieldError(`settings.${key}`, null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const updateSettingValue = (key: OnboardingSettingKey, value: string) => {
|
||||
setSettingsState((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], value },
|
||||
}))
|
||||
setFieldError(`settings.${key}`, null)
|
||||
}
|
||||
|
||||
const reviewSettings = Object.entries(settingsState)
|
||||
.filter(([, entry]) => entry.enabled && entry.value.trim())
|
||||
.map(([key, entry]) => ({
|
||||
definition: getFieldByKey(key as OnboardingSettingKey),
|
||||
value: entry.value.trim(),
|
||||
}))
|
||||
|
||||
const updateSiteField = (key: OnboardingSiteSettingKey, value: string | boolean) => {
|
||||
setSite((prev) => ({
|
||||
...prev,
|
||||
[key]:
|
||||
typeof value === 'boolean' ? value : value == null ? '' : typeof value === 'number' ? String(value) : value,
|
||||
}))
|
||||
setFieldError(key, null)
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
mutation,
|
||||
siteSchema,
|
||||
siteSchemaLoading,
|
||||
siteSchemaError,
|
||||
currentStepIndex,
|
||||
currentStep,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
jumpToStep,
|
||||
canNavigateTo: (index: number) => index <= currentStepIndex,
|
||||
tenant,
|
||||
admin,
|
||||
site,
|
||||
settingsState,
|
||||
acknowledged,
|
||||
setAcknowledged: (value: boolean) => {
|
||||
setAcknowledged(value)
|
||||
if (value) {
|
||||
setFieldError('review.ack', null)
|
||||
}
|
||||
},
|
||||
errors,
|
||||
updateTenantName,
|
||||
updateTenantSlug,
|
||||
updateAdminField,
|
||||
toggleSetting,
|
||||
updateSettingValue,
|
||||
updateSiteField,
|
||||
reviewSettings,
|
||||
maskSecret,
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { SchemaFormState } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSettingKey, OnboardingSiteSettingKey } from './constants'
|
||||
|
||||
export type TenantFormState = {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export type AdminFormState = {
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
export type SettingFormState = Record<
|
||||
OnboardingSettingKey,
|
||||
{
|
||||
enabled: boolean
|
||||
value: string
|
||||
}
|
||||
>
|
||||
|
||||
export type SiteFormState = SchemaFormState<OnboardingSiteSettingKey>
|
||||
|
||||
export type OnboardingErrors = Record<string, string>
|
||||
7
be/apps/dashboard/src/modules/welcome/api.ts
Normal file
7
be/apps/dashboard/src/modules/welcome/api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
export async function getWelcomeSiteSchema() {
|
||||
return await coreApi('/public/site-settings/welcome-schema', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { SchemaFormRendererUncontrolled } from '~/modules/schema-form/SchemaFormRenderer'
|
||||
import type { SchemaFormState, SchemaFormValue, UiFieldNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
type SiteSchemaFormProps<Key extends string> = {
|
||||
schema: UiSchema<Key>
|
||||
values: SchemaFormState<Key>
|
||||
errors: Record<string, string>
|
||||
onFieldChange: (key: Key, value: string | boolean) => void
|
||||
}
|
||||
|
||||
export const SiteSchemaForm = <Key extends string>({
|
||||
schema,
|
||||
values,
|
||||
errors,
|
||||
onFieldChange,
|
||||
}: SiteSchemaFormProps<Key>) => {
|
||||
const schemaWithErrors = useMemo(() => {
|
||||
const enhanceNode = (node: UiFieldNode<Key>): UiFieldNode<Key> => {
|
||||
const error = errors[node.key]
|
||||
if (!error) {
|
||||
return node
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
helperText: error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...schema,
|
||||
sections: schema.sections.map((section) => ({
|
||||
...section,
|
||||
children: section.children.map((child: UiFieldNode<Key> | any) => {
|
||||
if (child.type !== 'field') {
|
||||
return child
|
||||
}
|
||||
return enhanceNode(child)
|
||||
}),
|
||||
})),
|
||||
}
|
||||
}, [errors, schema])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SchemaFormRendererUncontrolled
|
||||
initialValues={values}
|
||||
schema={schemaWithErrors}
|
||||
onChange={(key: Key, value: SchemaFormValue) => {
|
||||
onFieldChange(key, typeof value === 'boolean' ? value : value == null ? '' : String(value))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OnboardingSiteSettingKey } from './constants'
|
||||
|
||||
const stringValue = (validator: z.ZodString) => z.preprocess((value) => (value == null ? '' : String(value)), validator)
|
||||
|
||||
const trimmed = (min: number, message: string) => stringValue(z.string().trim().min(min, { message }))
|
||||
|
||||
export const siteSettingsSchema = z
|
||||
@@ -16,8 +13,9 @@ export const siteSettingsSchema = z
|
||||
|
||||
export type SiteSettingsSchema = typeof siteSettingsSchema
|
||||
export type SiteSettingsValues = z.infer<SiteSettingsSchema>
|
||||
export type WelcomeSiteSettingKey = keyof SiteSettingsSchema['shape']
|
||||
|
||||
export const SITE_SETTINGS_KEYS = Object.keys(siteSettingsSchema.shape) as OnboardingSiteSettingKey[]
|
||||
export const SITE_SETTINGS_KEYS = Object.keys(siteSettingsSchema.shape) as WelcomeSiteSettingKey[]
|
||||
|
||||
export const DEFAULT_SITE_SETTINGS_VALUES: SiteSettingsValues = {
|
||||
'site.name': '',
|
||||
@@ -1,22 +1,5 @@
|
||||
import type { SchemaFormState, SchemaFormValue, UiFieldNode, UiNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSettingKey, SettingFieldDefinition } from './constants'
|
||||
import { ONBOARDING_SETTING_SECTIONS, ONBOARDING_STEPS } from './constants'
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES } from './siteSchema'
|
||||
import type { SettingFormState } from './types'
|
||||
|
||||
export function createInitialSettingsState(): SettingFormState {
|
||||
const state = {} as SettingFormState
|
||||
for (const section of ONBOARDING_SETTING_SECTIONS) {
|
||||
for (const field of section.fields) {
|
||||
state[field.key] = { enabled: false, value: '' }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export const maskSecret = (value: string) => (value ? '•'.repeat(Math.min(10, value.length)) : '')
|
||||
|
||||
export function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
@@ -26,31 +9,6 @@ export function slugify(value: string) {
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export function isLikelyEmail(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.includes('@')) {
|
||||
return false
|
||||
}
|
||||
const [local, domain] = trimmed.split('@')
|
||||
if (!local || !domain || domain.startsWith('.') || domain.endsWith('.')) {
|
||||
return false
|
||||
}
|
||||
return domain.includes('.')
|
||||
}
|
||||
|
||||
export const stepProgress = (index: number) => Math.round((index / (ONBOARDING_STEPS.length - 1 || 1)) * 100)
|
||||
|
||||
export function getFieldByKey(key: OnboardingSettingKey): SettingFieldDefinition {
|
||||
for (const section of ONBOARDING_SETTING_SECTIONS) {
|
||||
for (const field of section.fields) {
|
||||
if (field.key === key) {
|
||||
return field
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown onboarding setting key: ${key}`)
|
||||
}
|
||||
|
||||
const traverseSchemaNodes = <Key extends string>(
|
||||
nodes: ReadonlyArray<UiNode<Key>>,
|
||||
map: Map<Key, UiFieldNode<Key>>,
|
||||
@@ -71,16 +29,13 @@ export function collectSchemaFieldMap<Key extends string>(schema: UiSchema<Key>)
|
||||
return map
|
||||
}
|
||||
|
||||
export function collectSchemaFieldKeys<Key extends string>(schema: UiSchema<Key>): Key[] {
|
||||
return [...collectSchemaFieldMap(schema).keys()]
|
||||
}
|
||||
|
||||
export function createInitialSiteStateFromFieldMap<Key extends string>(
|
||||
fieldMap: Map<Key, UiFieldNode<Key>>,
|
||||
defaults: Record<string, SchemaFormValue | undefined>,
|
||||
): SchemaFormState<Key> {
|
||||
const state = {} as SchemaFormState<Key>
|
||||
for (const [key, field] of fieldMap) {
|
||||
const defaultValue = (DEFAULT_SITE_SETTINGS_VALUES as Record<string, SchemaFormValue | undefined>)[key as string]
|
||||
const defaultValue = defaults[key as string]
|
||||
if (defaultValue !== undefined) {
|
||||
state[key] = defaultValue
|
||||
continue
|
||||
@@ -96,10 +51,6 @@ export function createInitialSiteStateFromFieldMap<Key extends string>(
|
||||
return state
|
||||
}
|
||||
|
||||
export function createInitialSiteStateFromSchema<Key extends string>(schema: UiSchema<Key>): SchemaFormState<Key> {
|
||||
return createInitialSiteStateFromFieldMap(collectSchemaFieldMap(schema))
|
||||
}
|
||||
|
||||
export function coerceSiteFieldValue<Key extends string>(
|
||||
field: UiFieldNode<Key>,
|
||||
raw: unknown,
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Button, Input, Label } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons'
|
||||
import { useLogin } from '~/modules/auth/hooks/useLogin'
|
||||
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
|
||||
import { getTenantSlugFromHost } from '~/modules/auth/utils/domain'
|
||||
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
|
||||
|
||||
export function Component() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const { login, isLoading, error, clearError } = useLogin()
|
||||
const tenantSlug = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
return getTenantSlugFromHost(window.location.hostname)
|
||||
}, [])
|
||||
const showEmailLogin = !tenantSlug
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
@@ -36,88 +44,102 @@ export function Component() {
|
||||
<div className="relative flex min-h-dvh flex-1 flex-col">
|
||||
<div className="bg-background flex flex-1 items-center justify-center">
|
||||
<LinearBorderContainer>
|
||||
<form onSubmit={handleSubmit} className="bg-background-tertiary relative w-[600px]">
|
||||
<div className="p-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-text mb-2 text-3xl font-bold">Login</h1>
|
||||
<p className="text-text-secondary text-sm">Enter your credentials to access the dashboard</p>
|
||||
</div>
|
||||
{showEmailLogin ? (
|
||||
<form onSubmit={handleSubmit} className="bg-background-tertiary relative w-[600px]">
|
||||
<div className="p-10">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-text mb-2 text-3xl font-bold">Login</h1>
|
||||
<p className="text-text-secondary text-sm">Enter your credentials to access the dashboard</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="border-red/60 bg-red/10 mb-6 rounded-lg border px-4 py-3.5"
|
||||
{error && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="border-red/60 bg-red/10 mb-6 rounded-lg border px-4 py-3.5"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="i-lucide-circle-alert text-red mt-0.5 text-base" />
|
||||
<p className="text-red flex-1 text-sm">{error}</p>
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="w-full"
|
||||
disabled={!email.trim() || !password.trim()}
|
||||
isLoading={isLoading}
|
||||
loadingText="Signing in..."
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="i-lucide-circle-alert text-red mt-0.5 text-base" />
|
||||
<p className="text-red flex-1 text-sm">{error}</p>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="via-text/20 h-[0.5px] w-full bg-linear-to-r from-transparent to-transparent" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background-tertiary text-text-tertiary px-3 tracking-wide">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="mb-5 space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="mb-6 space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="w-full"
|
||||
disabled={!email.trim() || !password.trim()}
|
||||
isLoading={isLoading}
|
||||
loadingText="Signing in..."
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
{/* OR Divider */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="h-[0.5px] w-full bg-linear-to-r from-transparent via-text/20 to-transparent" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background-tertiary px-3 text-text-tertiary tracking-wide">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Auth Buttons */}
|
||||
<SocialAuthButtons layout="row" title="" />
|
||||
<SocialAuthButtons layout="row" title="" />
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="bg-background-tertiary relative w-[600px]">
|
||||
<div className="space-y-8 p-10">
|
||||
<div>
|
||||
<h1 className="text-text mb-2 text-3xl font-bold">Continue with your provider</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
This workspace uses your organization's identity provider for authentication. Choose a provider
|
||||
below to sign in.
|
||||
</p>
|
||||
</div>
|
||||
<SocialAuthButtons layout="row" title="" />
|
||||
<p className="text-text-tertiary text-xs">
|
||||
Need to access the root administrator account? Use the dashboard base domain to sign in with email and
|
||||
password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</LinearBorderContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { OnboardingWizard } from '~/modules/onboarding/components/OnboardingWizard'
|
||||
|
||||
export const Component = () => <OnboardingWizard />
|
||||
@@ -4,7 +4,7 @@ import { m } from 'motion/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
|
||||
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
|
||||
|
||||
function getCurrentHostname() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -42,7 +42,7 @@ export function Component() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button variant="primary" className="flex-1" onClick={() => navigate('/register', { replace: true })}>
|
||||
<Button variant="primary" className="flex-1" onClick={() => navigate('/welcome', { replace: true })}>
|
||||
Create a workspace
|
||||
</Button>
|
||||
<Button variant="ghost" className="flex-1" onClick={() => navigate('/login', { replace: true })}>
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
const RESERVED_SLUGS = ['admin', 'docs', 'support', 'status', 'api', 'assets', 'static', 'www'] as const
|
||||
// prettier-ignore
|
||||
export const RESERVED_SLUGS = [
|
||||
// 基础系统路径
|
||||
'admin', 'root', 'system', 'internal', 'core', 'config', 'setup', 'install', 'init',
|
||||
'docs', 'documentation', 'guide', 'help', 'faq', 'manual',
|
||||
'support', 'contact', 'feedback', 'status', 'about', 'legal', 'terms', 'privacy',
|
||||
|
||||
// 静态资源相关
|
||||
'api', 'apis', 'assets', 'static', 'cdn', 'storage', 'media', 'uploads', 'files',
|
||||
'images', 'img', 'videos', 'audio', 'documents', 'downloads', 'public', 'private',
|
||||
'shared', 'tmp', 'cache',
|
||||
|
||||
// 组织 / 团队
|
||||
'team', 'workspace', 'workspaces', 'project', 'projects',
|
||||
'organization', 'org', 'company', 'community', 'forum', 'group', 'groups',
|
||||
|
||||
// 用户 / 账户相关
|
||||
'user', 'users', 'profile', 'profiles', 'account', 'accounts', 'me', 'my',
|
||||
'auth', 'login', 'logout', 'register', 'signup', 'signin', 'forgot', 'reset', 'verify',
|
||||
|
||||
// 业务逻辑 / 控制面板
|
||||
'dashboard', 'panel', 'console', 'control', 'settings', 'preferences',
|
||||
'manage', 'management', 'adminpanel', 'portal',
|
||||
|
||||
// 通信 / 消息系统
|
||||
'chat', 'forum', 'discussion', 'thread', 'messaging', 'messages',
|
||||
'inbox', 'notifications', 'alerts', 'logs', 'events', 'activity', 'feed', 'timeline',
|
||||
|
||||
// 数据 / 分析
|
||||
'reports', 'analytics', 'metrics', 'insights', 'monitor', 'monitoring',
|
||||
|
||||
// 电商 / 支付
|
||||
'billing', 'payments', 'payment', 'subscriptions', 'subscription',
|
||||
'invoices', 'receipts', 'refunds', 'discounts', 'coupons', 'promotions',
|
||||
'offers', 'deals', 'sales', 'shop', 'store', 'purchases', 'orders', 'cart', 'checkout',
|
||||
|
||||
// 站点级别
|
||||
'www', 'home', 'landing', 'index', 'holding', 'test', 'dev', 'beta', 'staging',
|
||||
|
||||
// 其他常见保留词
|
||||
'search', 'explore', 'discover', 'new', 'create', 'edit', 'update', 'delete', 'remove',
|
||||
'api-docs', 'health', 'ping', 'robots', 'sitemap', 'manifest', 'favicon', 'version',
|
||||
'v1', 'v2', 'old', 'archive', 'preview', 'embed', 'share', 'link', 'connect', 'integrations',
|
||||
'oauth', 'callback', 'webhook', 'hooks',
|
||||
] as const
|
||||
export const RESERVED_TENANT_SLUGS = RESERVED_SLUGS
|
||||
|
||||
export type ReservedTenantSlug = (typeof RESERVED_TENANT_SLUGS)[number]
|
||||
|
||||
Reference in New Issue
Block a user