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 { describe, expect, it } from 'vitest'
|
||||||
import { hasAccess, rolesFromPayload } from './auth.js'
|
import { canApproveAccess, hasAccess, realmRolesFromPayload, rolesFromPayload } from './auth.js'
|
||||||
|
|
||||||
describe('FieldLens authorization isolation', () => {
|
describe('FieldLens authorization isolation', () => {
|
||||||
it('extracts FieldLens client roles and ignores realm/platform roles', () => {
|
it('extracts FieldLens client roles and ignores realm/platform roles for app access', () => {
|
||||||
const roles = rolesFromPayload({
|
const payload = {
|
||||||
resource_access: {
|
resource_access: {
|
||||||
'fieldlens-api': { roles: ['fieldlens-operator'] },
|
'fieldlens-api': { roles: ['fieldlens-operator'] },
|
||||||
},
|
},
|
||||||
realm_access: { roles: ['operator', 'admin', 'owner', 'offline_access'] },
|
realm_access: { roles: ['operator', 'admin', 'owner', 'offline_access'] },
|
||||||
|
}
|
||||||
|
expect(rolesFromPayload(payload)).toEqual(['fieldlens-operator'])
|
||||||
|
expect(realmRolesFromPayload(payload)).toEqual(['operator', 'admin', 'owner', 'offline_access'])
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(roles).toEqual(['fieldlens-operator'])
|
it('does not grant FieldLens operational access from generic Arrowstack roles', () => {
|
||||||
})
|
|
||||||
|
|
||||||
it('does not grant FieldLens access from generic Arrowstack roles', () => {
|
|
||||||
const platformIdentity = {
|
const platformIdentity = {
|
||||||
roles: ['operator', 'admin', 'owner'],
|
roles: ['operator', 'admin', 'owner'],
|
||||||
|
realmRoles: ['operator', 'admin', 'owner'],
|
||||||
clientId: 'some-arrowstack-client',
|
clientId: 'some-arrowstack-client',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +26,13 @@ describe('FieldLens authorization isolation', () => {
|
|||||||
expect(hasAccess('integration', platformIdentity)).toBe(false)
|
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', () => {
|
it('preserves the FieldLens role hierarchy', () => {
|
||||||
expect(hasAccess('operator', { roles: ['fieldlens-operator'] })).toBe(true)
|
expect(hasAccess('operator', { roles: ['fieldlens-operator'] })).toBe(true)
|
||||||
expect(hasAccess('operator', { roles: ['fieldlens-supervisor'] })).toBe(true)
|
expect(hasAccess('operator', { roles: ['fieldlens-supervisor'] })).toBe(true)
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express'
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
||||||
|
import { approvedRoleForSubject } from './access.js'
|
||||||
import { config } from './config.js'
|
import { config } from './config.js'
|
||||||
|
|
||||||
export type AuthIdentity = {
|
export type AuthIdentity = {
|
||||||
subject: string
|
subject: string
|
||||||
username?: string
|
username?: string
|
||||||
|
email?: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
realmRoles: string[]
|
||||||
clientId?: 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) {
|
function intendedForFieldLens(payload: JWTPayload) {
|
||||||
const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []
|
const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []
|
||||||
const azp = typeof payload.azp === 'string' ? payload.azp : ''
|
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' })
|
res.status(401).json({ error: 'invalid_token_target' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenRoles = rolesFromPayload(payload)
|
||||||
|
const approvedRole = await approvedRoleForSubject(payload.sub)
|
||||||
req.identity = {
|
req.identity = {
|
||||||
subject: payload.sub,
|
subject: payload.sub,
|
||||||
username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined,
|
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,
|
clientId: typeof payload.azp === 'string' ? payload.azp : undefined,
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
@ -83,6 +96,16 @@ export function hasAccess(
|
|||||||
return exactMachineIdentity || access[level].some((role) => identity?.roles.includes(role) ?? false)
|
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) {
|
export function requireAccess(level: keyof typeof access) {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!hasAccess(level, req.identity)) {
|
if (!hasAccess(level, req.identity)) {
|
||||||
@ -92,3 +115,11 @@ export function requireAccess(level: keyof typeof access) {
|
|||||||
next()
|
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 express from 'express'
|
||||||
import helmet from 'helmet'
|
import helmet from 'helmet'
|
||||||
import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts'
|
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 { config } from './config.js'
|
||||||
import { loadDeviceContext } from './context.js'
|
import { loadDeviceContext } from './context.js'
|
||||||
import { databaseReady, pool } from './db.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.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) => {
|
app.get('/api/v1/me', requireAccess('operator'), (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
subject: req.identity!.subject,
|
subject: req.identity!.subject,
|
||||||
|
|||||||
@ -2,17 +2,73 @@ import { useLiveQuery } from 'dexie-react-hooks'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { database, getLocalMetadata, type OutboxRecord } from '../../../packages/fieldlens-core/src/lib/database'
|
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'
|
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() {
|
export function HostStatus() {
|
||||||
const pending = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt).count(), []) ?? 0
|
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 errors = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt && Boolean(event.lastError)).count(), []) ?? 0
|
||||||
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
|
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
|
||||||
const [station, setStation] = useState('Local / unassigned')
|
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(() => {
|
useEffect(() => {
|
||||||
void initializeAuth().then((ok) => setAuthenticated(ok))
|
void initializeAuth().then((ok) => {
|
||||||
|
setAuthenticated(ok)
|
||||||
|
if (ok) void refreshAccess()
|
||||||
|
})
|
||||||
const refreshStation = () => {
|
const refreshStation = () => {
|
||||||
void getLocalMetadata<RegisteredDevice>('registeredDevice').then((device) => {
|
void getLocalMetadata<RegisteredDevice>('registeredDevice').then((device) => {
|
||||||
if (device?.station?.name) setStation(device.station.name)
|
if (device?.station?.name) setStation(device.station.name)
|
||||||
@ -23,12 +79,65 @@ export function HostStatus() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
async function toggleRequests() {
|
||||||
|
const next = !showRequests
|
||||||
|
setShowRequests(next)
|
||||||
|
if (next) await refreshRequests()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<aside className="host-status" aria-label="Arrowstack synchronization status">
|
<aside className="host-status" aria-label="Arrowstack synchronization status">
|
||||||
<span><strong>{station}</strong><small>{pending} pending{errors ? ` · ${errors} retrying` : ''}</small></span>
|
<span>
|
||||||
<button type="button" onClick={() => void (authenticated ? logout() : login())}>
|
<strong>{station}</strong>
|
||||||
{authenticated ? 'Sign out' : 'Sign in to sync'}
|
<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>
|
||||||
|
)}
|
||||||
|
<button type="button" className="secondary" onClick={() => void logout()}>Sign out</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</aside>
|
</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 })
|
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() {
|
export async function logout() {
|
||||||
if (!keycloak) return
|
if (!keycloak) return
|
||||||
await keycloak.logout({ redirectUri: window.location.origin })
|
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