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:
17
backend/src/routes/internal/auth.routes.js
Normal file
17
backend/src/routes/internal/auth.routes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
|
||||
|
||||
export const internalAuthRoutes = async (app) => {
|
||||
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!user) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
|
||||
})
|
||||
}
|
||||
// Attach to request for downstream permission checks
|
||||
request.ccUser = user
|
||||
return reply.send({ success: true, data: user })
|
||||
})
|
||||
}
|
||||
38
backend/src/routes/internal/cc-user.routes.js
Normal file
38
backend/src/routes/internal/cc-user.routes.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserByFirebaseUid, createCcUser, listCcUsers } from '../../services/cc-user.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
|
||||
request.ccUser = user
|
||||
}
|
||||
|
||||
export const ccUserRoutes = async (app) => {
|
||||
app.post('/', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'create')],
|
||||
}, async (request, reply) => {
|
||||
const { email, display_name, role_id } = request.body ?? {}
|
||||
if (!email || !display_name || !role_id) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'email, display_name, and role_id are required' } })
|
||||
}
|
||||
|
||||
// Create Firebase user with temporary password — admin will share credentials verbally
|
||||
const { initFirebase } = await import('../../plugins/firebase.js')
|
||||
const admin = (await import('firebase-admin')).default
|
||||
initFirebase()
|
||||
|
||||
const tempPassword = Math.random().toString(36).slice(-10) + 'A1!'
|
||||
const firebaseUser = await admin.auth().createUser({ email, password: tempPassword })
|
||||
|
||||
const user = await createCcUser({ firebase_uid: firebaseUser.uid, email, display_name, role_id })
|
||||
return reply.code(201).send({ success: true, data: user })
|
||||
})
|
||||
|
||||
app.get('/', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const { page = 1, limit = 20 } = request.query
|
||||
const result = await listCcUsers({ page: Number(page), limit: Number(limit) })
|
||||
return reply.send({ success: true, data: result })
|
||||
})
|
||||
}
|
||||
29
backend/src/routes/internal/config.routes.js
Normal file
29
backend/src/routes/internal/config.routes.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
|
||||
import { getAnonymityConfig, setAnonymityConfig } from '../../services/config.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
|
||||
request.ccUser = user
|
||||
}
|
||||
|
||||
export const internalConfigRoutes = async (app) => {
|
||||
app.get('/anonymity', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const config = await getAnonymityConfig()
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
app.patch('/anonymity', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { anonymity_enabled } = request.body ?? {}
|
||||
if (typeof anonymity_enabled !== 'boolean') {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'anonymity_enabled must be a boolean' } })
|
||||
}
|
||||
const config = await setAnonymityConfig(anonymity_enabled)
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
}
|
||||
45
backend/src/routes/internal/mitra.routes.js
Normal file
45
backend/src/routes/internal/mitra.routes.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
|
||||
import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
|
||||
request.ccUser = user
|
||||
}
|
||||
|
||||
export const mitraManagementRoutes = async (app) => {
|
||||
app.post('/', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'create')],
|
||||
}, async (request, reply) => {
|
||||
const { phone, display_name } = request.body ?? {}
|
||||
if (!phone || !display_name) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'phone and display_name are required' } })
|
||||
}
|
||||
const mitra = await createMitra({ phone, display_name })
|
||||
return reply.code(201).send({ success: true, data: mitra })
|
||||
})
|
||||
|
||||
app.get('/', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const { page = 1, limit = 20, is_active } = request.query
|
||||
const result = await listMitras({
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
is_active: is_active !== undefined ? is_active === 'true' : undefined,
|
||||
})
|
||||
return reply.send({ success: true, data: result })
|
||||
})
|
||||
|
||||
app.patch('/:id/status', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { is_active } = request.body ?? {}
|
||||
if (typeof is_active !== 'boolean') {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'is_active must be a boolean' } })
|
||||
}
|
||||
const mitra = await updateMitraStatus(request.params.id, is_active)
|
||||
return reply.send({ success: true, data: mitra })
|
||||
})
|
||||
}
|
||||
18
backend/src/routes/internal/roles.routes.js
Normal file
18
backend/src/routes/internal/roles.routes.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
|
||||
import { listRoles } from '../../services/roles.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
|
||||
request.ccUser = user
|
||||
}
|
||||
|
||||
export const rolesRoutes = async (app) => {
|
||||
app.get('/', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const roles = await listRoles()
|
||||
return reply.send({ success: true, data: roles })
|
||||
})
|
||||
}
|
||||
15
backend/src/routes/public/client.auth.routes.js
Normal file
15
backend/src/routes/public/client.auth.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
31
backend/src/routes/public/customer.routes.js
Normal file
31
backend/src/routes/public/customer.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
44
backend/src/routes/public/mitra.auth.routes.js
Normal file
44
backend/src/routes/public/mitra.auth.routes.js
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
9
backend/src/routes/public/shared.config.routes.js
Normal file
9
backend/src/routes/public/shared.config.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user