This commit is contained in:
parent
fc6dcaf87a
commit
9152cccb0a
110
apps/api/src/access.ts
Normal file
110
apps/api/src/access.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { pool } from './db.js'
|
||||
|
||||
export type AccessStatus = 'pending' | 'approved' | 'rejected'
|
||||
export type AppRole = 'fieldlens-operator' | 'fieldlens-supervisor' | 'fieldlens-admin'
|
||||
|
||||
export type AccessRequest = {
|
||||
subject: string
|
||||
username: string | null
|
||||
email: string | null
|
||||
status: AccessStatus
|
||||
approvedRole: AppRole | null
|
||||
requestedAt: string
|
||||
reviewedAt: string | null
|
||||
reviewedBy: string | null
|
||||
}
|
||||
|
||||
const APP_ROLES = new Set<AppRole>(['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin'])
|
||||
|
||||
function role(value: unknown): AppRole | null {
|
||||
return typeof value === 'string' && APP_ROLES.has(value as AppRole) ? value as AppRole : null
|
||||
}
|
||||
|
||||
export async function approvedRoleForSubject(subject: string): Promise<AppRole | null> {
|
||||
const result = await pool.query<{ approved_role: string | null }>(
|
||||
"SELECT approved_role FROM fieldlens_access_requests WHERE subject = $1 AND status = 'approved'",
|
||||
[subject],
|
||||
)
|
||||
return role(result.rows[0]?.approved_role)
|
||||
}
|
||||
|
||||
export async function getAccessRequest(subject: string): Promise<AccessRequest | null> {
|
||||
const result = await pool.query<AccessRequest>(`
|
||||
SELECT subject, username, email, status,
|
||||
approved_role AS "approvedRole",
|
||||
requested_at AS "requestedAt",
|
||||
reviewed_at AS "reviewedAt",
|
||||
reviewed_by AS "reviewedBy"
|
||||
FROM fieldlens_access_requests
|
||||
WHERE subject = $1
|
||||
`, [subject])
|
||||
return result.rows[0] ?? null
|
||||
}
|
||||
|
||||
export async function requestAccess(identity: { subject: string; username?: string; email?: string }) {
|
||||
const result = await pool.query<AccessRequest>(`
|
||||
INSERT INTO fieldlens_access_requests (subject, username, email, status)
|
||||
VALUES ($1, $2, $3, 'pending')
|
||||
ON CONFLICT (subject) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
email = EXCLUDED.email,
|
||||
status = CASE
|
||||
WHEN fieldlens_access_requests.status = 'approved' THEN 'approved'
|
||||
ELSE 'pending'
|
||||
END,
|
||||
requested_at = CASE
|
||||
WHEN fieldlens_access_requests.status = 'approved' THEN fieldlens_access_requests.requested_at
|
||||
ELSE now()
|
||||
END,
|
||||
reviewed_at = CASE
|
||||
WHEN fieldlens_access_requests.status = 'approved' THEN fieldlens_access_requests.reviewed_at
|
||||
ELSE NULL
|
||||
END,
|
||||
reviewed_by = CASE
|
||||
WHEN fieldlens_access_requests.status = 'approved' THEN fieldlens_access_requests.reviewed_by
|
||||
ELSE NULL
|
||||
END
|
||||
RETURNING subject, username, email, status,
|
||||
approved_role AS "approvedRole",
|
||||
requested_at AS "requestedAt",
|
||||
reviewed_at AS "reviewedAt",
|
||||
reviewed_by AS "reviewedBy"
|
||||
`, [identity.subject, identity.username ?? null, identity.email ?? null])
|
||||
return result.rows[0]!
|
||||
}
|
||||
|
||||
export async function listAccessRequests(status: AccessStatus = 'pending') {
|
||||
const result = await pool.query<AccessRequest>(`
|
||||
SELECT subject, username, email, status,
|
||||
approved_role AS "approvedRole",
|
||||
requested_at AS "requestedAt",
|
||||
reviewed_at AS "reviewedAt",
|
||||
reviewed_by AS "reviewedBy"
|
||||
FROM fieldlens_access_requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 100
|
||||
`, [status])
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function reviewAccessRequest(
|
||||
subject: string,
|
||||
decision: 'approved' | 'rejected',
|
||||
reviewer: string,
|
||||
) {
|
||||
const result = await pool.query<AccessRequest>(`
|
||||
UPDATE fieldlens_access_requests
|
||||
SET status = $2,
|
||||
approved_role = CASE WHEN $2 = 'approved' THEN 'fieldlens-operator' ELSE NULL END,
|
||||
reviewed_at = now(),
|
||||
reviewed_by = $3
|
||||
WHERE subject = $1
|
||||
RETURNING subject, username, email, status,
|
||||
approved_role AS "approvedRole",
|
||||
requested_at AS "requestedAt",
|
||||
reviewed_at AS "reviewedAt",
|
||||
reviewed_by AS "reviewedBy"
|
||||
`, [subject, decision, reviewer])
|
||||
return result.rows[0] ?? null
|
||||
}
|
||||
@ -1,21 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasAccess, rolesFromPayload } from './auth.js'
|
||||
import { canApproveAccess, hasAccess, realmRolesFromPayload, rolesFromPayload } from './auth.js'
|
||||
|
||||
describe('FieldLens authorization isolation', () => {
|
||||
it('extracts FieldLens client roles and ignores realm/platform roles', () => {
|
||||
const roles = rolesFromPayload({
|
||||
it('extracts FieldLens client roles and ignores realm/platform roles for app access', () => {
|
||||
const payload = {
|
||||
resource_access: {
|
||||
'fieldlens-api': { roles: ['fieldlens-operator'] },
|
||||
},
|
||||
realm_access: { roles: ['operator', 'admin', 'owner', 'offline_access'] },
|
||||
})
|
||||
|
||||
expect(roles).toEqual(['fieldlens-operator'])
|
||||
}
|
||||
expect(rolesFromPayload(payload)).toEqual(['fieldlens-operator'])
|
||||
expect(realmRolesFromPayload(payload)).toEqual(['operator', 'admin', 'owner', 'offline_access'])
|
||||
})
|
||||
|
||||
it('does not grant FieldLens access from generic Arrowstack roles', () => {
|
||||
it('does not grant FieldLens operational access from generic Arrowstack roles', () => {
|
||||
const platformIdentity = {
|
||||
roles: ['operator', 'admin', 'owner'],
|
||||
realmRoles: ['operator', 'admin', 'owner'],
|
||||
clientId: 'some-arrowstack-client',
|
||||
}
|
||||
|
||||
@ -25,6 +26,13 @@ describe('FieldLens authorization isolation', () => {
|
||||
expect(hasAccess('integration', platformIdentity)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows only admin/owner realm identities to approve access without granting app access', () => {
|
||||
expect(canApproveAccess({ roles: [], realmRoles: ['admin'] })).toBe(true)
|
||||
expect(canApproveAccess({ roles: [], realmRoles: ['owner'] })).toBe(true)
|
||||
expect(canApproveAccess({ roles: [], realmRoles: ['operator'] })).toBe(false)
|
||||
expect(hasAccess('operator', { roles: [], clientId: 'fieldlens' })).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the FieldLens role hierarchy', () => {
|
||||
expect(hasAccess('operator', { roles: ['fieldlens-operator'] })).toBe(true)
|
||||
expect(hasAccess('operator', { roles: ['fieldlens-supervisor'] })).toBe(true)
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
||||
import { approvedRoleForSubject } from './access.js'
|
||||
import { config } from './config.js'
|
||||
|
||||
export type AuthIdentity = {
|
||||
subject: string
|
||||
username?: string
|
||||
email?: string
|
||||
roles: string[]
|
||||
realmRoles: string[]
|
||||
clientId?: string
|
||||
}
|
||||
|
||||
@ -34,6 +37,11 @@ export function rolesFromPayload(payload: JWTPayload) {
|
||||
])]
|
||||
}
|
||||
|
||||
export function realmRolesFromPayload(payload: JWTPayload) {
|
||||
const realmAccess = payload.realm_access as { roles?: unknown } | undefined
|
||||
return [...new Set(strings(realmAccess?.roles))]
|
||||
}
|
||||
|
||||
function intendedForFieldLens(payload: JWTPayload) {
|
||||
const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []
|
||||
const azp = typeof payload.azp === 'string' ? payload.azp : ''
|
||||
@ -56,10 +64,15 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti
|
||||
res.status(401).json({ error: 'invalid_token_target' })
|
||||
return
|
||||
}
|
||||
|
||||
const tokenRoles = rolesFromPayload(payload)
|
||||
const approvedRole = await approvedRoleForSubject(payload.sub)
|
||||
req.identity = {
|
||||
subject: payload.sub,
|
||||
username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined,
|
||||
roles: rolesFromPayload(payload),
|
||||
email: typeof payload.email === 'string' ? payload.email : undefined,
|
||||
roles: [...new Set([...tokenRoles, ...(approvedRole ? [approvedRole] : [])])],
|
||||
realmRoles: realmRolesFromPayload(payload),
|
||||
clientId: typeof payload.azp === 'string' ? payload.azp : undefined,
|
||||
}
|
||||
next()
|
||||
@ -83,6 +96,16 @@ export function hasAccess(
|
||||
return exactMachineIdentity || access[level].some((role) => identity?.roles.includes(role) ?? false)
|
||||
}
|
||||
|
||||
export function canApproveAccess(
|
||||
identity: Pick<AuthIdentity, 'roles' | 'realmRoles'> | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
identity?.roles.includes('fieldlens-admin')
|
||||
|| identity?.realmRoles.includes('admin')
|
||||
|| identity?.realmRoles.includes('owner'),
|
||||
)
|
||||
}
|
||||
|
||||
export function requireAccess(level: keyof typeof access) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!hasAccess(level, req.identity)) {
|
||||
@ -92,3 +115,11 @@ export function requireAccess(level: keyof typeof access) {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAccessApprover(req: Request, res: Response, next: NextFunction) {
|
||||
if (!canApproveAccess(req.identity)) {
|
||||
res.status(403).json({ error: 'forbidden' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
@ -3,7 +3,8 @@ import cors from 'cors'
|
||||
import express from 'express'
|
||||
import helmet from 'helmet'
|
||||
import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts'
|
||||
import { authenticate, requireAccess } from './auth.js'
|
||||
import { getAccessRequest, listAccessRequests, requestAccess, reviewAccessRequest } from './access.js'
|
||||
import { authenticate, canApproveAccess, requireAccess, requireAccessApprover } from './auth.js'
|
||||
import { config } from './config.js'
|
||||
import { loadDeviceContext } from './context.js'
|
||||
import { databaseReady, pool } from './db.js'
|
||||
@ -72,6 +73,55 @@ app.get('/api/health/ready', async (_req, res) => {
|
||||
|
||||
app.use('/api/v1', authenticate)
|
||||
|
||||
app.get('/api/v1/access/me', async (req, res) => {
|
||||
const request = await getAccessRequest(req.identity!.subject)
|
||||
const roles = req.identity!.roles.filter((role) => role.startsWith('fieldlens-'))
|
||||
res.json({
|
||||
status: roles.length > 0 ? 'approved' : request?.status ?? 'none',
|
||||
role: roles[0] ?? request?.approvedRole ?? null,
|
||||
canApprove: canApproveAccess(req.identity),
|
||||
username: req.identity!.username ?? null,
|
||||
email: req.identity!.email ?? null,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/v1/access/request', async (req, res) => {
|
||||
const request = await requestAccess(req.identity!)
|
||||
log('info', 'access_requested', { subject: req.identity!.subject, status: request.status })
|
||||
res.status(request.status === 'approved' ? 200 : 202).json({
|
||||
status: request.status,
|
||||
role: request.approvedRole,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/v1/access/requests', requireAccessApprover, async (req, res) => {
|
||||
const status = req.query.status === 'approved' || req.query.status === 'rejected'
|
||||
? req.query.status
|
||||
: 'pending'
|
||||
const requests = await listAccessRequests(status)
|
||||
res.json({ requests })
|
||||
})
|
||||
|
||||
app.post('/api/v1/access/requests/:subject/approve', requireAccessApprover, async (req, res) => {
|
||||
const request = await reviewAccessRequest(req.params.subject, 'approved', req.identity!.subject)
|
||||
if (!request) {
|
||||
res.status(404).json({ error: 'access_request_not_found' })
|
||||
return
|
||||
}
|
||||
log('info', 'access_approved', { subject: request.subject, reviewer: req.identity!.subject })
|
||||
res.json({ status: request.status, role: request.approvedRole })
|
||||
})
|
||||
|
||||
app.post('/api/v1/access/requests/:subject/reject', requireAccessApprover, async (req, res) => {
|
||||
const request = await reviewAccessRequest(req.params.subject, 'rejected', req.identity!.subject)
|
||||
if (!request) {
|
||||
res.status(404).json({ error: 'access_request_not_found' })
|
||||
return
|
||||
}
|
||||
log('info', 'access_rejected', { subject: request.subject, reviewer: req.identity!.subject })
|
||||
res.json({ status: request.status })
|
||||
})
|
||||
|
||||
app.get('/api/v1/me', requireAccess('operator'), (req, res) => {
|
||||
res.json({
|
||||
subject: req.identity!.subject,
|
||||
|
||||
@ -2,17 +2,73 @@ import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { database, getLocalMetadata, type OutboxRecord } from '../../../packages/fieldlens-core/src/lib/database'
|
||||
import { initializeAuth, keycloak, login, logout } from './auth'
|
||||
import { getAccessToken, initializeAuth, keycloak, login, logout, register } from './auth'
|
||||
import type { RegisteredDevice } from './sync'
|
||||
|
||||
type AccessState = {
|
||||
status: 'none' | 'pending' | 'approved' | 'rejected'
|
||||
role: string | null
|
||||
canApprove: boolean
|
||||
username: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
type AccessRequest = {
|
||||
subject: string
|
||||
username: string | null
|
||||
email: string | null
|
||||
status: string
|
||||
requestedAt: string
|
||||
}
|
||||
|
||||
async function accessFetch(path: string, init: RequestInit = {}) {
|
||||
const token = await getAccessToken()
|
||||
if (!token) throw new Error('Authentication unavailable')
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
if (init.body) headers.set('Content-Type', 'application/json')
|
||||
return fetch(path, { ...init, headers })
|
||||
}
|
||||
|
||||
export function HostStatus() {
|
||||
const pending = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt).count(), []) ?? 0
|
||||
const errors = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt && Boolean(event.lastError)).count(), []) ?? 0
|
||||
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
|
||||
const [station, setStation] = useState('Local / unassigned')
|
||||
const [access, setAccess] = useState<AccessState | null>(null)
|
||||
const [requests, setRequests] = useState<AccessRequest[]>([])
|
||||
const [showRequests, setShowRequests] = useState(false)
|
||||
|
||||
async function refreshAccess() {
|
||||
const response = await accessFetch('/api/v1/access/me')
|
||||
if (!response.ok) return
|
||||
const next = await response.json() as AccessState
|
||||
setAccess(next)
|
||||
if (next.canApprove && showRequests) await refreshRequests()
|
||||
}
|
||||
|
||||
async function refreshRequests() {
|
||||
const response = await accessFetch('/api/v1/access/requests')
|
||||
if (!response.ok) return
|
||||
const body = await response.json() as { requests: AccessRequest[] }
|
||||
setRequests(body.requests)
|
||||
}
|
||||
|
||||
async function submitRequest() {
|
||||
const response = await accessFetch('/api/v1/access/request', { method: 'POST' })
|
||||
if (response.ok) await refreshAccess()
|
||||
}
|
||||
|
||||
async function review(subject: string, decision: 'approve' | 'reject') {
|
||||
const response = await accessFetch(`/api/v1/access/requests/${encodeURIComponent(subject)}/${decision}`, { method: 'POST' })
|
||||
if (response.ok) await refreshRequests()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void initializeAuth().then((ok) => setAuthenticated(ok))
|
||||
void initializeAuth().then((ok) => {
|
||||
setAuthenticated(ok)
|
||||
if (ok) void refreshAccess()
|
||||
})
|
||||
const refreshStation = () => {
|
||||
void getLocalMetadata<RegisteredDevice>('registeredDevice').then((device) => {
|
||||
if (device?.station?.name) setStation(device.station.name)
|
||||
@ -23,12 +79,65 @@ export function HostStatus() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
async function toggleRequests() {
|
||||
const next = !showRequests
|
||||
setShowRequests(next)
|
||||
if (next) await refreshRequests()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="host-status" aria-label="Arrowstack synchronization status">
|
||||
<span><strong>{station}</strong><small>{pending} pending{errors ? ` · ${errors} retrying` : ''}</small></span>
|
||||
<button type="button" onClick={() => void (authenticated ? logout() : login())}>
|
||||
{authenticated ? 'Sign out' : 'Sign in to sync'}
|
||||
</button>
|
||||
</aside>
|
||||
<>
|
||||
<aside className="host-status" aria-label="Arrowstack synchronization status">
|
||||
<span>
|
||||
<strong>{station}</strong>
|
||||
<small>
|
||||
{authenticated && access?.status !== 'approved'
|
||||
? access?.status === 'pending' ? 'Access pending approval' : access?.status === 'rejected' ? 'Access request rejected' : 'No FieldLens access'
|
||||
: `${pending} pending${errors ? ` · ${errors} retrying` : ''}`}
|
||||
</small>
|
||||
</span>
|
||||
{!authenticated ? (
|
||||
<>
|
||||
<button type="button" onClick={() => void login()}>Sign in</button>
|
||||
<button type="button" className="secondary" onClick={() => void register()}>Create account</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(access?.status === 'none' || access?.status === 'rejected') && (
|
||||
<button type="button" onClick={() => void submitRequest()}>Request access</button>
|
||||
)}
|
||||
{access?.canApprove && (
|
||||
<button type="button" className="secondary" onClick={() => void toggleRequests()}>
|
||||
Requests
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="secondary" onClick={() => void logout()}>Sign out</button>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{showRequests && access?.canApprove && (
|
||||
<section className="access-panel" aria-label="FieldLens access requests">
|
||||
<header>
|
||||
<strong>Access requests</strong>
|
||||
<button type="button" onClick={() => setShowRequests(false)}>×</button>
|
||||
</header>
|
||||
{requests.length === 0 ? (
|
||||
<p>No pending requests.</p>
|
||||
) : requests.map((request) => (
|
||||
<article key={request.subject}>
|
||||
<span>
|
||||
<strong>{request.username || request.email || request.subject}</strong>
|
||||
<small>{request.email || 'Keycloak account'} · {new Date(request.requestedAt).toLocaleString()}</small>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" onClick={() => void review(request.subject, 'approve')}>Approve</button>
|
||||
<button type="button" className="secondary" onClick={() => void review(request.subject, 'reject')}>Reject</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -48,6 +48,12 @@ export async function login() {
|
||||
await keycloak.login({ redirectUri: window.location.href })
|
||||
}
|
||||
|
||||
export async function register() {
|
||||
if (!keycloak) return
|
||||
await initializeAuth()
|
||||
await keycloak.register({ redirectUri: window.location.href })
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
if (!keycloak) return
|
||||
await keycloak.logout({ redirectUri: window.location.origin })
|
||||
|
||||
@ -1 +1 @@
|
||||
.host-status{position:fixed;z-index:80;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;align-items:center;gap:10px;padding:7px 8px 7px 11px;border:1px solid #314039;border-radius:10px;background:#17211fe8;color:#fff;box-shadow:0 5px 20px #0003;backdrop-filter:blur(10px);font:11px/1.2 system-ui,sans-serif}.host-status span{display:grid;gap:2px;min-width:0}.host-status strong{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-status small{color:#aebbb6}.host-status button{border:0;border-radius:7px;padding:7px 9px;background:#f4d35e;color:#17211f;font-weight:800}@media(max-width:760px){.host-status{top:max(7px,env(safe-area-inset-top));right:max(7px,env(safe-area-inset-right));max-width:75vw}.host-status strong{max-width:100px}.host-status button{padding:6px 8px;font-size:9px}}
|
||||
.host-status{position:fixed;z-index:80;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;align-items:center;gap:8px;padding:7px 8px 7px 11px;border:1px solid #314039;border-radius:10px;background:#17211fe8;color:#fff;box-shadow:0 5px 20px #0003;backdrop-filter:blur(10px);font:11px/1.2 system-ui,sans-serif}.host-status span{display:grid;gap:2px;min-width:0}.host-status strong{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-status small{color:#aebbb6}.host-status button,.access-panel button{border:0;border-radius:7px;padding:7px 9px;background:#f4d35e;color:#17211f;font-weight:800}.host-status button.secondary,.access-panel button.secondary{background:#314039;color:#fff}.access-panel{position:fixed;z-index:81;top:58px;right:max(10px,env(safe-area-inset-right));width:min(430px,calc(100vw - 20px));max-height:70vh;overflow:auto;padding:12px;border:1px solid #314039;border-radius:12px;background:#17211ff7;color:#fff;box-shadow:0 12px 36px #0006;font:12px/1.35 system-ui,sans-serif}.access-panel header,.access-panel article{display:flex;align-items:center;justify-content:space-between;gap:12px}.access-panel header{padding-bottom:10px;border-bottom:1px solid #314039}.access-panel header button{font-size:18px;line-height:1;padding:4px 8px}.access-panel article{padding:11px 0;border-bottom:1px solid #26332e}.access-panel article:last-child{border-bottom:0}.access-panel article span{display:grid;gap:3px;min-width:0}.access-panel article small{color:#aebbb6;overflow-wrap:anywhere}.access-panel article div{display:flex;gap:6px;flex-shrink:0}.access-panel p{color:#aebbb6}@media(max-width:760px){.host-status{top:max(7px,env(safe-area-inset-top));right:max(7px,env(safe-area-inset-right));left:max(7px,env(safe-area-inset-left));max-width:none;flex-wrap:wrap}.host-status span{margin-right:auto}.host-status strong{max-width:120px}.host-status button{padding:6px 8px;font-size:9px}.access-panel{top:94px;right:7px;width:calc(100vw - 14px)}}
|
||||
|
||||
19
migrations/003_access_requests.sql
Normal file
19
migrations/003_access_requests.sql
Normal file
@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS fieldlens_access_requests (
|
||||
subject text PRIMARY KEY,
|
||||
username text,
|
||||
email text,
|
||||
status text NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
approved_role text CHECK (
|
||||
approved_role IS NULL OR approved_role IN (
|
||||
'fieldlens-operator',
|
||||
'fieldlens-supervisor',
|
||||
'fieldlens-admin'
|
||||
)
|
||||
),
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
reviewed_at timestamptz,
|
||||
reviewed_by text
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fieldlens_access_requests_status_requested
|
||||
ON fieldlens_access_requests(status, requested_at);
|
||||
Loading…
x
Reference in New Issue
Block a user