Phase 1 scaffold: auth for all apps

- Backend: Fastify with two listeners (public + internal), routes, services, DB migration + seed
- client_app: Flutter with BLoC, all auth screens (welcome, display name, register, OTP, force-register)
- mitra_app: Flutter with BLoC, OTP-only login
- control_center: React + Vite, email/password login, mitra/user management, anonymity settings
- Docs: phase1 plan, API contract, client app mockup
- CLAUDE.md and shared memory for all subprojects

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 10:08:42 +08:00
commit a7a2a32d27
85 changed files with 3953 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
export const clientAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
if (!customer) {
return reply.code(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'Customer account not found' },
})
}
return reply.send({ success: true, data: customer })
})
}

View File

@@ -0,0 +1,31 @@
import { authenticate } from '../../plugins/auth.js'
import { createAnonymousCustomer, linkCustomerAccount } from '../../services/customer.service.js'
export const customerRoutes = async (app) => {
app.post('/anonymous', async (request, reply) => {
const { display_name } = request.body ?? {}
if (!display_name?.trim()) {
return reply.code(422).send({
success: false,
error: { code: 'DISPLAY_NAME_REQUIRED', message: 'Display name is required' },
})
}
const customer = await createAnonymousCustomer({ display_name: display_name.trim() })
return reply.code(201).send({ success: true, data: customer })
})
app.post('/link', { preHandler: authenticate }, async (request, reply) => {
const { customer_id } = request.body ?? {}
const firebase_uid = request.firebaseUser.uid
if (!customer_id) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'customer_id is required' },
})
}
const customer = await linkCustomerAccount({ customer_id, firebase_uid })
return reply.send({ success: true, data: customer })
})
}

View File

@@ -0,0 +1,44 @@
import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid, getMitraByPhone, setMitraFirebaseUid } from '../../services/mitra.service.js'
export const mitraAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
const { uid, phone_number } = request.firebaseUser
// First try lookup by firebase_uid (returning user)
let mitra = await getMitraByFirebaseUid(uid)
// First-time login: link firebase_uid to mitra record via phone number
if (!mitra && phone_number) {
mitra = await getMitraByPhone(phone_number)
if (mitra) {
await setMitraFirebaseUid(mitra.id, uid)
}
}
if (!mitra) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found. Contact your administrator.' },
})
}
if (!mitra.is_active) {
return reply.code(403).send({
success: false,
error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive. Contact your administrator.' },
})
}
return reply.send({
success: true,
data: {
id: mitra.id,
display_name: mitra.display_name,
phone: mitra.phone,
is_active: mitra.is_active,
created_at: mitra.created_at,
},
})
})
}

View File

@@ -0,0 +1,9 @@
import { authenticate } from '../../plugins/auth.js'
import { getAnonymityConfig } from '../../services/config.service.js'
export const sharedConfigRoutes = async (app) => {
app.get('/anonymity', { preHandler: authenticate }, async (request, reply) => {
const config = await getAnonymityConfig()
return reply.send({ success: true, data: config })
})
}