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:
Innei
2025-11-10 00:55:20 +08:00
parent 5cae9431c0
commit 4064c1341c
68 changed files with 1095 additions and 2815 deletions

View File

@@ -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"]

View File

@@ -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:*",

View File

@@ -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',
})
})
})

View 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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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}`)

View 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
}
}
}

View 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 {}

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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,
)

View File

@@ -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 {}

View File

@@ -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,
},
}
},

View 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}`)
}
}

View File

@@ -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,

View File

@@ -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,
}
}
}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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')
}
}

View File

@@ -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 {}

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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 {}

View File

@@ -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> {

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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()

View File

@@ -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'],
}

View File

@@ -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>
)

View File

@@ -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&apos;s identity provider to create a workspace. We&apos;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&apos;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&apos;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>
)

View File

@@ -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',

View File

@@ -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}

View File

@@ -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'

View File

@@ -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) {

View File

@@ -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,
}

View 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
}

View File

@@ -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,
})
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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.',
},
]

View File

@@ -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,
}
}

View File

@@ -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>

View 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',
})
}

View File

@@ -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>
)
}

View File

@@ -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': '',

View File

@@ -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,

View File

@@ -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&apos;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>

View File

@@ -1,3 +0,0 @@
import { OnboardingWizard } from '~/modules/onboarding/components/OnboardingWizard'
export const Component = () => <OnboardingWizard />

View File

@@ -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 })}>

View File

@@ -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]