151 lines
5.6 KiB
TypeScript
151 lines
5.6 KiB
TypeScript
import { randomBytes } from 'crypto'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { createUser, getUserFromRequest , requireRole } from '@/lib/auth'
|
|
import { getDatabase, logAuditEvent } from '@/lib/db'
|
|
|
|
function makeUsernameFromEmail(email: string): string {
|
|
const base = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user'
|
|
return base.slice(0, 28)
|
|
}
|
|
|
|
function ensureUniqueUsername(base: string): string {
|
|
const db = getDatabase()
|
|
let candidate = base
|
|
let i = 0
|
|
while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {
|
|
i += 1
|
|
candidate = `${base.slice(0, 24)}-${i}`
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const auth = requireRole(request, 'viewer')
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
|
|
|
const user = getUserFromRequest(request)
|
|
if (!user || user.role !== 'admin') {
|
|
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
|
}
|
|
|
|
const db = getDatabase()
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS access_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider TEXT NOT NULL DEFAULT 'google',
|
|
email TEXT NOT NULL,
|
|
provider_user_id TEXT,
|
|
display_name TEXT,
|
|
avatar_url TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
requested_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
last_attempt_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
reviewed_by TEXT,
|
|
reviewed_at INTEGER,
|
|
review_note TEXT,
|
|
approved_user_id INTEGER
|
|
)
|
|
`)
|
|
|
|
const status = String(request.nextUrl.searchParams.get('status') || 'all')
|
|
const rows = status === 'all'
|
|
? db.prepare("SELECT * FROM access_requests ORDER BY status = 'pending' DESC, last_attempt_at DESC, id DESC").all()
|
|
: db.prepare('SELECT * FROM access_requests WHERE status = ? ORDER BY last_attempt_at DESC, id DESC').all(status)
|
|
|
|
return NextResponse.json({ requests: rows })
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const admin = getUserFromRequest(request)
|
|
if (!admin || admin.role !== 'admin') {
|
|
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
|
}
|
|
|
|
const db = getDatabase()
|
|
const body = await request.json().catch(() => ({}))
|
|
const requestId = Number(body?.request_id)
|
|
const action = String(body?.action || '')
|
|
const role = String(body?.role || 'viewer') as 'admin' | 'operator' | 'viewer'
|
|
const note = body?.note ? String(body.note) : null
|
|
|
|
if (!Number.isInteger(requestId) || requestId <= 0) {
|
|
return NextResponse.json({ error: 'request_id is required' }, { status: 400 })
|
|
}
|
|
if (!['approve', 'reject'].includes(action)) {
|
|
return NextResponse.json({ error: 'action must be approve or reject' }, { status: 400 })
|
|
}
|
|
if (!['admin', 'operator', 'viewer'].includes(role)) {
|
|
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
|
}
|
|
|
|
const reqRow = db.prepare('SELECT * FROM access_requests WHERE id = ?').get(requestId) as any
|
|
if (!reqRow) return NextResponse.json({ error: 'Request not found' }, { status: 404 })
|
|
|
|
if (action === 'reject') {
|
|
db.prepare(`
|
|
UPDATE access_requests
|
|
SET status = 'rejected', reviewed_by = ?, reviewed_at = (unixepoch()), review_note = ?
|
|
WHERE id = ?
|
|
`).run(admin.username, note, requestId)
|
|
|
|
logAuditEvent({
|
|
action: 'access_request_rejected',
|
|
actor: admin.username,
|
|
actor_id: admin.id,
|
|
detail: { request_id: requestId, email: reqRow.email, note },
|
|
})
|
|
|
|
return NextResponse.json({ ok: true })
|
|
}
|
|
|
|
const email = String(reqRow.email || '').toLowerCase()
|
|
const providerUserId = reqRow.provider_user_id ? String(reqRow.provider_user_id) : null
|
|
const displayName = String(reqRow.display_name || email.split('@')[0] || 'Google User')
|
|
const avatarUrl = reqRow.avatar_url ? String(reqRow.avatar_url) : null
|
|
|
|
const user = db.transaction(() => {
|
|
const existing = db.prepare('SELECT * FROM users WHERE lower(email) = ? OR (provider = ? AND provider_user_id = ?) ORDER BY id ASC LIMIT 1').get(email, 'google', providerUserId || '') as any
|
|
|
|
let userId: number
|
|
if (existing) {
|
|
db.prepare(`
|
|
UPDATE users
|
|
SET provider = 'google', provider_user_id = ?, email = ?, avatar_url = COALESCE(?, avatar_url), is_approved = 1, role = ?, approved_by = ?, approved_at = (unixepoch()), updated_at = (unixepoch())
|
|
WHERE id = ?
|
|
`).run(providerUserId, email, avatarUrl, role, admin.username, existing.id)
|
|
userId = Number(existing.id)
|
|
} else {
|
|
const username = ensureUniqueUsername(makeUsernameFromEmail(email))
|
|
const randomPwd = randomBytes(24).toString('hex')
|
|
const created = createUser(username, randomPwd, displayName, role, {
|
|
provider: 'google',
|
|
provider_user_id: providerUserId,
|
|
email,
|
|
avatar_url: avatarUrl,
|
|
is_approved: 1,
|
|
approved_by: admin.username,
|
|
approved_at: Math.floor(Date.now() / 1000),
|
|
})
|
|
userId = created.id
|
|
}
|
|
|
|
db.prepare(`
|
|
UPDATE access_requests
|
|
SET status = 'approved', reviewed_by = ?, reviewed_at = (unixepoch()), review_note = ?, approved_user_id = ?
|
|
WHERE id = ?
|
|
`).run(admin.username, note, userId, requestId)
|
|
|
|
return db.prepare('SELECT id, username, display_name, role, provider, email, avatar_url, is_approved FROM users WHERE id = ?').get(userId)
|
|
})() as any
|
|
|
|
logAuditEvent({
|
|
action: 'access_request_approved',
|
|
actor: admin.username,
|
|
actor_id: admin.id,
|
|
detail: { request_id: requestId, email, role, user_id: user?.id, note },
|
|
})
|
|
|
|
return NextResponse.json({ ok: true, user })
|
|
}
|