Wire the browser client to the real account and organization API

The pages under web/ were a static mockup with hardcoded names and counts. They
now call the real backend, and the areas with no backend are explicit empty
states rather than invented numbers.

Real: sign in, the mandatory first-login password change, sign out, the account
table with filters and lifecycle actions, organization create/rename/re-slug/
status, the audit log, and dashboard counts scoped to the caller. The project
workspace keeps its Studio identity and its real session wiring, but its
project, maintenance, and statistics areas now say which backend work is
pending instead of showing sample figures.

Removed the mock release-readiness pipeline and the fake production metrics
rather than restyling them.

The client depends on properties the server already enforces, and states them
where a future editor will look:

- No secret is sent by a client. Account provisioning has no password field;
  the service generates the one-time password and returns it once.
- That one-time password lives in exactly one variable, is cleared when the
  reveal panel closes and on pagehide, and is never written to storage, a
  cookie, the URL, the page title, or the console.
- Every unsafe request carries X-CSRF-Token read fresh from the readable
  cookie, and GET /api/health on page load bootstraps it because the
  middleware issues none on an unsafe method and login is exempt.
- Nothing is trusted from the browser. Controls a role cannot use are hidden,
  and the service re-checks each one.

Rendered markup is built by an el() helper that routes all server-supplied text
through textContent, with no innerHTML anywhere. The response CSP is
script-src 'self' and style-src 'self', so an inline script or style attribute
would be refused by the browser; there is none.

Verification, and its limits. The banned-pattern grep for inline script, inline
handlers, style attributes, innerHTML, storage, and console output comes back
empty. Replaying the exact request sequence the client performs against a real
uvicorn server confirmed all 86 contract assertions: every field the
JavaScript reads is present, the CSRF cookie is issued by the health probe, the
one-time password is absent from every later response, and the error codes the
UI branches on are the ones the API returns.

The client was NOT executed in a browser. No desktop browser is attached to
this session and no JavaScript runtime is installed on this host, so the
one-time-password reveal, the modal focus trap, and the role gating are
verified by reading and by the contract replay, not by driving a real browser.
That gap needs closing before this is trusted.

web/README.md replaces the mock description, including the run command and the
fact that this is not a production deployment: create_app still refuses to
start in production because the PostgreSQL adapter does not exist.
This commit is contained in:
umbra audio
2026-09-26 20:13:46 -07:00
parent 0b9c4d4220
commit 08a44dbacd
8 changed files with 3666 additions and 259 deletions

View File

@@ -1,58 +1,93 @@
# UmbraTrans static UI prototype
# UmbraTrans web application
This directory contains a local, mock-data prototype for review.
The browser client and the account/organization API it talks to.
It does not connect to PostgreSQL, authenticate accounts, issue passwords, or change production data. The admin page demonstrates the intended flows only.
**This is not a mock.** Every page calls the real backend under `/api`. The
three routes below are served from the same origin as the API by
`umbratrans_web`, because the session cookie is `HttpOnly` and the CSRF token
travels as a header, and the application deliberately installs no CORS
middleware. A separately hosted frontend could not call it at all.
## Run under systemd
## What is real
The prototype is served by `umbratrans-web-prototype.service`, which restarts
on failure and applies a restrictive sandbox (no root, no new privileges, empty
capability set, read-only home and system, system call filtering). It listens on
plain HTTP on `8442`; Caddy terminates TLS for
`https://umbratrans.bdmurphy.me/` and proxies here.
| Surface | Backend |
| --- | --- |
| Sign in, forced first-login password change, sign out | Real, Argon2id and server-side sessions |
| Accounts: list, filter, provision, suspend, reactivate, deactivate, revoke sessions | Real |
| Organizations: list, create, rename, re-slug, suspend, reactivate, archive | Real |
| Audit log | Real |
| Dashboard counts | Real, scoped to the caller |
| Project workspace contents, maintenance, statistics | **Not built yet** |
```bash
systemctl --user status umbratrans-web-prototype.service
systemctl --user restart umbratrans-web-prototype.service
journalctl --user -u umbratrans-web-prototype.service -f
```
The unbuilt areas render as explicit empty states that name the missing backend
work. No page displays a fabricated name, count, or metric.
The unit is tracked at `systemd/umbratrans-web-prototype.service`.
## Running it
Two notes on that unit:
- A rootless install under `~/.config/systemd/user/` must drop `User=`. A
`--user` manager already runs as `umbra` and cannot setuid, so `User=`
produces `status=216/GROUP`. The tracked copy keeps `User=umbra` because it
is written for a system install under `/etc/systemd/system`.
- `Linger=no` for this account, so the service stops when the last session ends
and will not start at boot. `sudo loginctl enable-linger umbra` fixes that.
## Run in the foreground
Useful for iterating on the markup without touching the service.
In development the API serves this directory. From the repository root:
```bash
cd /home/umbra/src/UmbraTrans
python3 -m http.server 8442 --bind 0.0.0.0 --directory web
UMBRA_ENV=development \
UMBRATRANS_REPOSITORY=json \
UMBRATRANS_REPOSITORY_PATH=.local/data/development/umbratrans-web/dev-accounts.json \
UMBRATRANS_STATIC_ROOT=/home/umbra/src/UmbraTrans/web \
UMBRATRANS_ALLOWED_HOSTS=127.0.0.1,localhost \
/home/umbra/.local/bin/uv run python -m umbratrans_web serve \
--host 127.0.0.1 --port 8400 --repository json
```
## Review URLs
There is no first administrator until one is provisioned through the operator
command, which reads the one-time password from a hidden prompt:
- `http://<host>:8442/`
- `http://<host>:8442/workspace.html`
- `http://<host>:8442/uadmin/`
- `https://umbratrans.bdmurphy.me/` and `https://umbratrans.bdmurphy.me/uadmin/`
```bash
UMBRA_ENV=development \
/home/umbra/.local/bin/uv run umbratrans-bootstrap-admin \
--username Umbraadmin \
--repository-path .local/data/development/umbratrans-web/dev-accounts.json
```
## Known limitation of the prototype server
`--repository json` keeps accounts across restarts. `--repository memory` starts
empty every time.
`python3 -m http.server` lists directory contents for any path without an
`index.html`, so `/assets/` renders a file listing. It serves no secrets, but
it is one of the reasons the FastAPI application in `umbratrans_web/` will
replace this server rather than run alongside it. It also has no request size
limits, no timeouts, and no security headers.
## Routes
For production, keep TLS at the approved reverse proxy, bind the application to
the required interface, and implement the authentication and authorization
adapter described in the UI design document.
- `/` — sign in, and the mandatory password change for a temporary-password account
- `/uadmin/` — control panel: dashboard, accounts, organizations, audit
- `/workspace.html` — project workspace
## Security properties the client relies on
- **No secret is ever sent by a client.** `POST /api/uadmin/accounts` has no
password field; the service generates the one-time password. The request
schema refuses unknown fields, so a submitted `password` is rejected before a
handler sees it.
- **A one-time password is shown exactly once**, in the `otp-modal` panel. It is
held in one variable, cleared when the panel closes and on `pagehide`, and is
never written to `localStorage`, `sessionStorage`, a cookie, the URL, the page
title, or the console.
- **Every unsafe request carries `X-CSRF-Token`**, read fresh from the readable
`umbratrans_csrf` cookie. `GET /api/health` on page load bootstraps that
cookie, because the middleware does not issue one on an unsafe method and
`POST /api/auth/login` is exempt.
- **No inline script, no inline style, no `innerHTML`.** The server sends
`script-src 'self'; style-src 'self'`, so an inline `<script>` or a `style=`
attribute would be refused by the browser. All server-supplied text is
rendered with `textContent` through the `el()` helper in `api.js`.
- **Nothing is trusted from the browser.** The UI hides controls a role cannot
use, but the service re-checks every one of them; a forged identifier is
refused with the same generic error as any other forbidden request.
## Not a production deployment
The application fails closed in production: the PostgreSQL adapter does not
exist, so `create_app` raises rather than starting against an unreviewed store.
Serving this directory is therefore a development arrangement, and the reviewed
migration in `umbratrans_web/migrations/` has not been applied.
## Systemd
`umbratrans-web-prototype.service` currently serves this directory with
`python3 -m http.server`, which is static only and cannot reach the API. It
exists so the port stays occupied and the public hostname keeps answering. See
`systemd/umbratrans-web-prototype.service`.

File diff suppressed because it is too large Load Diff

739
web/assets/api.js Normal file
View File

@@ -0,0 +1,739 @@
/*
* UmbraTrans browser API client, DOM helpers, and modal utility.
*
* Rules enforced by this file, deliberately rather than by convention:
*
* - Every request is same-origin and sends `credentials: "same-origin"`.
* - Every non-GET /api request carries an `X-CSRF-Token` header whose value is
* read fresh from the readable `umbratrans_csrf` cookie on each call.
* `POST /api/auth/login` is the only exempt route. Nothing here can turn
* CSRF enforcement off.
* - No value that came from the API is ever turned into markup. `el()` builds
* nodes and assigns text with `textContent`; the HTML-parsing sinks (raw
* markup assignment, adjacent-HTML insertion, and document writing) are not
* used anywhere in this file or in the page scripts.
* - A one-time password is never logged, never written to storage, never put
* in a URL, and never passed to a toast. Only the provisioning dialog
* receives it, from the single response that contains it.
*
* This file runs on all three pages and assumes almost nothing about the DOM,
* so every lookup is guarded.
*/
(function (global) {
'use strict';
var CSRF_COOKIE = 'umbratrans_csrf';
var CSRF_HEADER = 'X-CSRF-Token';
var UNSAFE_METHODS = { POST: true, PUT: true, PATCH: true, DELETE: true };
var CSRF_EXEMPT = { '/api/auth/login': true };
var PASSWORD_MIN_LENGTH = 14;
/**
* Token returned in a response body. The server sends the same value in the
* cookie, so this is only a fallback for the pathological case where the
* browser withheld the cookie. It is never persisted.
*/
var fallbackCsrfToken = '';
var sessionLostHandler = null;
var passwordChangeHandler = null;
/* ---------------------------------------------------------------- errors */
/**
* A failed API call. Carries the server's stable error code, its sanitized
* message, and the optional `fields` / `requirements` lists. It never carries
* a submitted value: the backend strips those before responding.
*/
function ApiError(status, code, message, fields, requirements) {
this.name = 'ApiError';
this.status = status || 0;
this.code = code || 'request_failed';
this.message = message || 'The request could not be completed.';
this.fields = Array.isArray(fields) ? fields.slice() : [];
this.requirements = Array.isArray(requirements) ? requirements.slice() : [];
}
ApiError.prototype = Object.create(Error.prototype);
ApiError.prototype.constructor = ApiError;
function describeError(error) {
if (!error) {
return 'The request could not be completed.';
}
if (error instanceof ApiError) {
return error.message;
}
return 'The request could not be completed.';
}
/* ---------------------------------------------------------------- cookie */
function readCookie(name) {
var jar = typeof document === 'undefined' ? '' : document.cookie;
if (!jar) {
return '';
}
var parts = jar.split(';');
for (var i = 0; i < parts.length; i += 1) {
var part = parts[i];
var eq = part.indexOf('=');
if (eq < 0) {
continue;
}
if (part.slice(0, eq).trim() === name) {
var raw = part.slice(eq + 1).trim();
try {
return decodeURIComponent(raw);
} catch (error) {
return raw;
}
}
}
return '';
}
function csrfToken() {
return readCookie(CSRF_COOKIE) || fallbackCsrfToken || '';
}
/* ------------------------------------------------------------- requests */
function buildQuery(params) {
if (!params) {
return '';
}
var search = new URLSearchParams();
Object.keys(params).forEach(function (key) {
var value = params[key];
if (value === null || value === undefined || value === '') {
return;
}
search.set(key, String(value));
});
var text = search.toString();
return text ? '?' + text : '';
}
function noteSessionFailure(error) {
if (error.status === 401 && error.code === 'authentication_required') {
if (typeof sessionLostHandler === 'function') {
sessionLostHandler(error);
}
return;
}
if (error.status === 403 && error.code === 'password_change_required') {
if (typeof passwordChangeHandler === 'function') {
passwordChangeHandler(error);
}
}
}
/**
* Perform one API call.
*
* @param {string} path Same-origin path beginning with `/api`.
* @param {{method?: string, body?: unknown, params?: object, signal?: AbortSignal}} [options]
* @returns {Promise<object|null>} Parsed JSON body, or null when empty.
*/
function request(path, options) {
var opts = options || {};
var method = String(opts.method || 'GET').toUpperCase();
var init = {
method: method,
credentials: 'same-origin',
cache: 'no-store',
headers: { Accept: 'application/json' }
};
if (opts.signal) {
init.signal = opts.signal;
}
if (UNSAFE_METHODS[method] && !CSRF_EXEMPT[path]) {
var token = csrfToken();
if (!token) {
return Promise.reject(
new ApiError(
0,
'csrf_token_missing',
'This page has not received its security token yet. Reload and try again.'
)
);
}
init.headers[CSRF_HEADER] = token;
}
if (opts.body !== undefined && opts.body !== null) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(opts.body);
}
return global.fetch(path + buildQuery(opts.params), init).then(
function (response) {
var body = null;
var contentType = response.headers.get('content-type') || '';
if (contentType.indexOf('application/json') !== -1) {
return response.json().then(
function (parsed) {
body = parsed;
return { response: response, body: body };
},
function (readError) {
// A body that cannot be read is either an abort or a truncated
// response. Let an abort propagate so the caller recognises it as
// a superseded request rather than as an empty result.
if (readError && readError.name === 'AbortError') {
throw readError;
}
return { response: response, body: null };
}
);
}
return { response: response, body: null };
}
).then(function (result) {
var response = result.response;
var body = result.body;
if (body && typeof body.csrf_token === 'string' && body.csrf_token) {
fallbackCsrfToken = body.csrf_token;
}
if (response.ok) {
return body;
}
var error = new ApiError(
response.status,
'request_failed',
'The request could not be completed.'
);
if (body && body.error && typeof body.error === 'object') {
error.code = typeof body.error.code === 'string' ? body.error.code : error.code;
if (typeof body.error.message === 'string' && body.error.message) {
error.message = body.error.message;
}
error.fields = Array.isArray(body.error.fields) ? body.error.fields : [];
error.requirements = Array.isArray(body.error.requirements) ? body.error.requirements : [];
}
noteSessionFailure(error);
throw error;
}, function (networkError) {
if (networkError && networkError.name === 'AbortError') {
throw networkError;
}
var error = new ApiError(
0,
'network_error',
'The server could not be reached. Check your connection and retry.'
);
throw error;
});
}
/* ------------------------------------------------------------------ auth */
/**
* Unauthenticated probe. Also the CSRF bootstrap: any GET /api response
* issues the readable double-submit cookie when the browser does not have
* one yet, so calling this on page load guarantees the header is available
* for the first state-changing request.
*/
function health() {
return request('/api/health');
}
function login(username, password, signal) {
return request('/api/auth/login', {
method: 'POST',
body: { username: username, password: password },
signal: signal
});
}
function changeFirstPassword(currentPassword, newPassword, signal) {
return request('/api/auth/change-first-password', {
method: 'POST',
body: { current_password: currentPassword, new_password: newPassword },
signal: signal
});
}
function me(signal) {
return request('/api/auth/me', { signal: signal });
}
function logout(signal) {
return request('/api/auth/logout', { method: 'POST', signal: signal });
}
function logoutEverywhere(signal) {
return request('/api/auth/logout-everywhere', { method: 'POST', signal: signal });
}
/* ----------------------------------------------------------------- admin */
function getSummary(signal) {
return request('/api/uadmin/summary', { signal: signal });
}
function listAccounts(filters, signal) {
return request('/api/uadmin/accounts', { params: filters, signal: signal });
}
/**
* Provision an account. The response may carry `temporary_password`; that
* value is returned to the caller untouched and is never logged, stored, or
* interpolated into any other string.
*/
function createAccount(payload, signal) {
return request('/api/uadmin/accounts', { method: 'POST', body: payload, signal: signal });
}
function setAccountStatus(userId, status, reason, signal) {
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/status', {
method: 'PATCH',
body: { status: status, reason: reason === '' ? null : reason },
signal: signal
});
}
function revokeAccountSessions(userId, signal) {
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/revoke-sessions', {
method: 'POST',
signal: signal
});
}
function listOrganizations(filters, signal) {
return request('/api/uadmin/organizations', { params: filters, signal: signal });
}
function createOrganization(payload, signal) {
return request('/api/uadmin/organizations', { method: 'POST', body: payload, signal: signal });
}
function updateOrganization(organizationId, payload, signal) {
return request('/api/uadmin/organizations/' + encodeURIComponent(organizationId), {
method: 'PATCH',
body: payload,
signal: signal
});
}
function setOrganizationStatus(organizationId, status, reason, signal) {
return request(
'/api/uadmin/organizations/' + encodeURIComponent(organizationId) + '/status',
{ method: 'PATCH', body: { status: status, reason: reason === '' ? null : reason }, signal: signal }
);
}
function listAudit(filters, signal) {
return request('/api/uadmin/audit', { params: filters, signal: signal });
}
/* --------------------------------------------------------- DOM utilities */
function appendChild(node, child) {
if (child === null || child === undefined || child === false) {
return;
}
if (Array.isArray(child)) {
child.forEach(function (item) {
appendChild(node, item);
});
return;
}
if (typeof child === 'string' || typeof child === 'number') {
node.appendChild(document.createTextNode(String(child)));
return;
}
if (child && typeof child === 'object' && child.nodeType) {
node.appendChild(child);
}
}
/**
* Build one element.
*
* `class` and `text` are applied as properties; `dataset` and `on` take
* objects; a `true` value sets a bare attribute; anything else becomes a
* string attribute. Text always goes through `textContent`.
*/
function el(tag, props, children) {
var node = document.createElement(tag);
if (props) {
Object.keys(props).forEach(function (key) {
var value = props[key];
if (value === null || value === undefined || value === false) {
return;
}
if (key === 'class') {
node.className = value;
} else if (key === 'text') {
node.textContent = String(value);
} else if (key === 'dataset') {
Object.keys(value).forEach(function (dataKey) {
node.dataset[dataKey] = String(value[dataKey]);
});
} else if (key === 'on') {
Object.keys(value).forEach(function (type) {
node.addEventListener(type, value[type]);
});
} else if (value === true) {
node.setAttribute(key, '');
} else {
node.setAttribute(key, String(value));
}
});
}
appendChild(node, children);
return node;
}
function clearNode(node) {
if (!node) {
return;
}
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
function setText(node, value) {
if (!node) {
return;
}
node.textContent = value === null || value === undefined ? '' : String(value);
}
function query(selector, root) {
return (root || document).querySelector(selector);
}
function queryAll(selector, root) {
return Array.prototype.slice.call((root || document).querySelectorAll(selector));
}
function show(node, visible) {
if (!node) {
return;
}
if (visible) {
node.removeAttribute('hidden');
} else {
node.setAttribute('hidden', '');
}
}
function setBusy(node, busy) {
if (!node) {
return;
}
if (busy) {
node.setAttribute('aria-busy', 'true');
} else {
node.removeAttribute('aria-busy');
}
}
function disable(node, disabled) {
if (!node) {
return;
}
node.disabled = Boolean(disabled);
}
/* ------------------------------------------------------------ formatting */
var dateTimeFormat = null;
function formatDateTime(value) {
if (!value) {
return '—';
}
var parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return '—';
}
if (!dateTimeFormat) {
try {
dateTimeFormat = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
});
} catch (error) {
dateTimeFormat = null;
}
}
if (!dateTimeFormat) {
return parsed.toISOString();
}
return dateTimeFormat.format(parsed);
}
var LABELS = {
platform_admin: 'Platform administrator',
organization_admin: 'Organization administrator',
member: 'Member',
active: 'Active',
suspended: 'Suspended',
deactivated: 'Deactivated',
archived: 'Archived',
local_password: 'Local password',
oidc: 'OIDC',
temporary_password: 'Temporary password',
success: 'Success',
failure: 'Failure',
denied: 'Denied',
revoked: 'Revoked',
expired: 'Expired'
};
function label(value) {
if (value === null || value === undefined || value === '') {
return '—';
}
var text = String(value);
if (LABELS[text]) {
return LABELS[text];
}
return text.replace(/[_.]+/g, ' ').replace(/^./, function (first) {
return first.toUpperCase();
});
}
function shortId(value) {
if (!value) {
return '—';
}
return String(value);
}
function initials(displayName, username) {
var source = (displayName || username || '').trim();
if (!source) {
return '?';
}
var pieces = source.split(/\s+/).filter(function (piece) {
return piece.length > 0;
});
if (pieces.length === 0) {
return '?';
}
if (pieces.length === 1) {
return pieces[0].slice(0, 2).toUpperCase();
}
return (pieces[0].charAt(0) + pieces[pieces.length - 1].charAt(0)).toUpperCase();
}
var PASSWORD_RULE_TEXT = {
not_empty: 'must not be empty',
min_length: 'must be at least ' + PASSWORD_MIN_LENGTH + ' characters',
max_length: 'is too long',
no_surrounding_whitespace: 'must not begin or end with whitespace',
must_not_contain_username: 'must not contain your username',
must_differ_from_current: 'must differ from your current password',
unmet: 'does not meet the required policy'
};
/**
* Render a password-policy failure as rule names only. The candidate value is
* never involved, and the server never sends it.
*/
function describeRequirements(error) {
if (!error || !error.requirements || error.requirements.length === 0) {
return describeError(error);
}
return error.requirements
.map(function (rule) {
return PASSWORD_RULE_TEXT[rule] || label(rule);
})
.join('; ');
}
/* ----------------------------------------------------------------- toast */
var toastTimer = null;
function showToast(message, tone) {
var toast = document.getElementById('toast');
if (!toast) {
return;
}
clearNode(toast);
toast.appendChild(document.createTextNode(String(message)));
toast.classList.add('toast--visible');
if (tone) {
toast.classList.add('toast--' + tone);
}
if (toastTimer) {
global.clearTimeout(toastTimer);
}
toastTimer = global.setTimeout(function () {
toast.classList.remove('toast--visible');
clearNode(toast);
}, 5000);
}
/* ----------------------------------------------------------------- modal */
var FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusableWithin(root) {
return queryAll(FOCUSABLE, root).filter(function (node) {
if (node.hidden || node.closest('[hidden]')) {
return false;
}
return node.getClientRects().length > 0;
});
}
/**
* Wrap a `[hidden]` backdrop element. The dialog traps Tab, closes on Escape
* and on a backdrop click, and returns focus to whatever opened it.
*/
function createModal(backdrop, options) {
var settings = options || {};
var dialog = backdrop ? backdrop.querySelector('[role="dialog"]') : null;
var lastFocused = null;
var open = false;
function onKeydown(event) {
if (!open) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key !== 'Tab') {
return;
}
var focusable = focusableWithin(dialog || backdrop);
if (focusable.length === 0) {
event.preventDefault();
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
var active = document.activeElement;
if (event.shiftKey) {
if (active === first || !focusable.indexOf(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last) {
event.preventDefault();
first.focus();
}
}
function onBackdropClick(event) {
if (event.target === backdrop) {
close();
}
}
function openModal() {
if (!backdrop || open) {
return;
}
lastFocused = document.activeElement;
backdrop.removeAttribute('hidden');
document.documentElement.classList.add('modal-open');
open = true;
var target = settings.initialFocus ? query(settings.initialFocus, dialog || backdrop) : null;
var focusable = focusableWithin(dialog || backdrop);
if (!target && focusable.length > 0) {
target = focusable[0];
}
if (target && typeof target.focus === 'function') {
target.focus();
}
}
function close() {
if (!backdrop || !open) {
return;
}
open = false;
backdrop.setAttribute('hidden', '');
document.documentElement.classList.remove('modal-open');
if (typeof settings.onClose === 'function') {
settings.onClose();
}
if (lastFocused && typeof lastFocused.focus === 'function' && document.contains(lastFocused)) {
lastFocused.focus();
}
lastFocused = null;
}
if (backdrop) {
backdrop.addEventListener('keydown', onKeydown);
backdrop.addEventListener('mousedown', onBackdropClick);
}
return {
backdrop: backdrop,
dialog: dialog,
isOpen: function () {
return open;
},
open: openModal,
close: close
};
}
/* ---------------------------------------------------------------- export */
global.UmbraApi = {
ApiError: ApiError,
PASSWORD_MIN_LENGTH: PASSWORD_MIN_LENGTH,
health: health,
login: login,
changeFirstPassword: changeFirstPassword,
me: me,
logout: logout,
logoutEverywhere: logoutEverywhere,
getSummary: getSummary,
listAccounts: listAccounts,
createAccount: createAccount,
setAccountStatus: setAccountStatus,
revokeAccountSessions: revokeAccountSessions,
listOrganizations: listOrganizations,
createOrganization: createOrganization,
updateOrganization: updateOrganization,
setOrganizationStatus: setOrganizationStatus,
listAudit: listAudit,
request: request,
el: el,
clear: clearNode,
setText: setText,
query: query,
queryAll: queryAll,
show: show,
setBusy: setBusy,
disable: disable,
formatDateTime: formatDateTime,
label: label,
shortId: shortId,
initials: initials,
describeError: describeError,
describeRequirements: describeRequirements,
showToast: showToast,
createModal: createModal,
readCookie: readCookie,
setSessionLostHandler: function (handler) {
sessionLostHandler = handler;
},
setPasswordChangeHandler: function (handler) {
passwordChangeHandler = handler;
},
forgetLocalState: function () {
fallbackCsrfToken = '';
}
};
})(window);

View File

@@ -1,56 +1,484 @@
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
/*
* UmbraTrans sign-in and workspace page behaviour.
*
* This file is shared by `index.html` and `workspace.html`. Every DOM lookup
* is guarded so the two pages can share it: each setup function returns
* immediately when the markup it drives is not present.
*
* Sign-in posts the account *name* (never an email address) to
* `POST /api/auth/login`, then routes by role. A session that must replace its
* temporary password is held on the page and navigation is locked until the
* change succeeds.
*/
(function (global) {
'use strict';
function showToast(message) {
const toast = $('#toast');
if (!toast) return;
toast.textContent = message;
toast.classList.add('toast--visible');
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove('toast--visible'), 3600);
}
var api = global.UmbraApi;
if (!api) {
return;
}
function setupLogin() {
const form = $('#login-form');
if (!form) return;
const message = $('#login-message');
const email = $('#email');
const password = $('#password');
const demo = $('#demo-login');
var query = api.query;
var queryAll = api.queryAll;
const completeDemoLogin = (value) => {
email.value = value || 'alex.morgan@example.com';
password.value = 'prototype-only';
sessionStorage.setItem('ut-demo-user', email.value);
window.location.href = 'workspace.html';
};
/* ------------------------------------------------------------- sign-in */
demo?.addEventListener('click', () => completeDemoLogin());
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!email.value.trim() || !password.value) {
message.textContent = 'Enter an account and password to continue.';
message.className = 'form-message form-message--error';
function setMessage(node, text, tone) {
if (!node) {
return;
}
message.textContent = 'Prototype login accepted locally. No credentials were transmitted.';
message.className = 'form-message form-message--success';
window.setTimeout(() => completeDemoLogin(email.value.trim()), 350);
});
}
node.textContent = text;
node.className = 'form-message' + (tone ? ' form-message--' + tone : '');
}
function setupWorkspace() {
$$('[data-demo-action]').forEach((button) => {
button.addEventListener('click', () => {
if (button.textContent.trim() === 'Sign out') {
sessionStorage.removeItem('ut-demo-user');
window.location.href = 'index.html';
function destinationFor(role) {
return role === 'platform_admin' || role === 'organization_admin' ? 'uadmin/' : 'workspace.html';
}
function showPasswordToggle(button, input) {
if (!button || !input) {
return;
}
var reveal = input.type === 'password';
button.setAttribute('aria-pressed', reveal ? 'true' : 'false');
button.textContent = reveal ? 'Hide' : 'Show';
button.setAttribute('aria-label', (reveal ? 'Hide' : 'Show') + ' ' + (input.getAttribute('data-field-label') || 'password'));
}
function wirePasswordToggle(buttonId, inputId) {
var button = query('#' + buttonId);
var input = query('#' + inputId);
if (!button || !input) {
return;
}
showPasswordToggle(button, input);
button.addEventListener('click', function () {
var reveal = input.type === 'password';
input.type = reveal ? 'text' : 'password';
showPasswordToggle(button, input);
});
}
function setupSignIn() {
var loginForm = query('#login-form');
var passwordForm = query('#password-form');
var loginView = query('#login-view');
var passwordView = query('#password-view');
if (!loginView || !passwordView) {
return;
}
var loginMessage = query('#login-message');
var passwordMessage = query('#password-message');
var loginSubmit = query('#login-submit');
var passwordSubmit = query('#password-submit');
var usernameInput = query('#login-username');
var passwordInput = query('#login-password');
var currentInput = query('#current-password');
var newInput = query('#new-password');
var confirmInput = query('#confirm-password');
var adminEntry = query('#admin-entry');
var adminLink = query('#admin-link');
var serviceNote = query('#service-note');
var navigationLocked = false;
function onBeforeUnload(event) {
if (!navigationLocked) {
return undefined;
}
event.preventDefault();
event.returnValue = '';
return '';
}
function setNavigationLocked(locked) {
navigationLocked = Boolean(locked);
if (navigationLocked) {
global.addEventListener('beforeunload', onBeforeUnload);
} else {
global.removeEventListener('beforeunload', onBeforeUnload);
}
}
// While a mandatory password change is outstanding, no link on this page
// may navigate away. The session stays usable for the change itself.
document.addEventListener(
'click',
function (event) {
if (!navigationLocked) {
return;
}
var target = event.target;
var anchor = target && typeof target.closest === 'function' ? target.closest('a[href]') : null;
if (!anchor) {
return;
}
event.preventDefault();
setMessage(
passwordMessage,
'Finish changing your password before leaving this page.',
'error'
);
},
true
);
function openPasswordView() {
api.show(loginView, false);
api.show(passwordView, true);
api.show(adminEntry, false);
api.show(adminLink, false);
setNavigationLocked(true);
setMessage(passwordMessage, '', null);
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', '#change-password');
}
if (currentInput) {
currentInput.focus();
}
}
function openLoginView() {
setNavigationLocked(false);
api.show(passwordView, false);
api.show(loginView, true);
api.show(adminEntry, true);
api.show(adminLink, true);
}
function busy(isBusy) {
api.disable(loginSubmit, isBusy);
api.disable(passwordSubmit, isBusy);
api.setBusy(loginForm, isBusy);
api.setBusy(passwordForm, isBusy);
}
function describeLoginFailure(error) {
if (!error) {
return 'Sign in failed. Try again.';
}
if (error.code === 'invalid_credentials' || error.status === 401) {
// Deliberately identical for an unknown account, a wrong password, a
// locked account, and a suspended tenant. The response must not be
// usable to discover which accounts exist.
return 'Sign in failed. Check your account name and password.';
}
if (error.code === 'password_policy') {
return api.describeRequirements(error);
}
return api.describeError(error);
}
if (loginForm) {
loginForm.addEventListener('submit', function (event) {
event.preventDefault();
var username = usernameInput ? usernameInput.value.trim() : '';
var password = passwordInput ? passwordInput.value : '';
if (!username || !password) {
setMessage(loginMessage, 'Enter your account name and password to continue.', 'error');
return;
}
setMessage(loginMessage, 'Signing in…', null);
busy(true);
api.login(username, password).then(
function (data) {
busy(false);
if (passwordInput) {
passwordInput.value = '';
}
if (data && data.must_change_password) {
openPasswordView();
return;
}
var role = data && data.user ? data.user.role : null;
global.location.replace(destinationFor(role));
},
function (error) {
busy(false);
if (passwordInput) {
passwordInput.value = '';
}
setMessage(loginMessage, describeLoginFailure(error), 'error');
if (usernameInput) {
usernameInput.focus();
}
}
);
});
}
if (passwordForm) {
passwordForm.addEventListener('submit', function (event) {
event.preventDefault();
var current = currentInput ? currentInput.value : '';
var next = newInput ? newInput.value : '';
var confirmation = confirmInput ? confirmInput.value : '';
if (!current || !next) {
setMessage(passwordMessage, 'Enter your current password and a new password.', 'error');
return;
}
if (next !== confirmation) {
setMessage(passwordMessage, 'The two new passwords do not match.', 'error');
return;
}
if (next.length < api.PASSWORD_MIN_LENGTH) {
setMessage(
passwordMessage,
'The new password must be at least ' + api.PASSWORD_MIN_LENGTH + ' characters.',
'error'
);
return;
}
setMessage(passwordMessage, 'Updating your password…', null);
busy(true);
api.changeFirstPassword(current, next).then(
function (data) {
busy(false);
if (currentInput) {
currentInput.value = '';
}
if (newInput) {
newInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
if (data && data.must_change_password) {
setMessage(
passwordMessage,
'The password was not changed. Choose a new one and try again.',
'error'
);
return;
}
setNavigationLocked(false);
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', global.location.pathname);
}
var role = data && data.user ? data.user.role : null;
global.location.replace(destinationFor(role));
},
function (error) {
busy(false);
if (currentInput) {
currentInput.value = '';
}
if (newInput) {
newInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
if (error && (error.code === 'invalid_credentials' || error.status === 401)) {
if (error.code === 'invalid_credentials') {
setMessage(
passwordMessage,
'The temporary password you entered is not correct.',
'error'
);
} else {
// No usable session: the change cannot be attempted. Return to
// the sign-in form so the account can sign in again.
openLoginView();
setMessage(loginMessage, 'Your session ended. Sign in again to continue.', 'error');
}
} else if (error && error.code === 'password_policy') {
setMessage(passwordMessage, api.describeRequirements(error), 'error');
} else {
setMessage(passwordMessage, api.describeError(error), 'error');
}
if (currentInput) {
currentInput.focus();
}
}
);
});
}
var signoutInstead = query('#password-signout');
if (signoutInstead) {
signoutInstead.addEventListener('click', function () {
busy(true);
// Whether or not the revocation count comes back, the server clears the
// session cookie on this route, so the page always returns to sign-in.
var leave = function () {
setNavigationLocked(false);
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', global.location.pathname);
}
global.location.replace(global.location.pathname);
};
api.logout().then(leave, leave);
});
}
var policyUsername = query('#policy-username');
if (policyUsername && usernameInput) {
var describePolicy = function () {
var name = usernameInput.value.trim();
if (!name) {
policyUsername.textContent = 'Must not contain your account name.';
return;
}
policyUsername.textContent = 'Must not contain “' + name + '”.';
};
usernameInput.addEventListener('input', describePolicy);
describePolicy();
}
wirePasswordToggle('toggle-login-password', 'login-password');
wirePasswordToggle('toggle-current-password', 'current-password');
wirePasswordToggle('toggle-new-password', 'new-password');
// `GET /api/health` is unauthenticated and issues the CSRF cookie the
// mandatory change needs, so it runs before any form is submitted.
api.health().then(
function (data) {
if (!serviceNote) {
return;
}
var parts = [];
if (data && data.service) {
parts.push(data.service);
}
if (data && data.version) {
parts.push('version ' + data.version);
}
if (data && data.environment) {
parts.push(data.environment);
}
serviceNote.textContent = parts.length > 0 ? parts.join(' · ') : 'Service reachable.';
serviceNote.classList.add('prototype-note--live');
},
function () {
if (serviceNote) {
serviceNote.textContent = 'The service could not be reached. Sign-in may fail.';
}
}
);
if (global.location.hash === '#change-password') {
// Another page redirected here because this session still owes a
// password change. Show the view without trying to resolve the session:
// `GET /api/auth/me` rejects a session in this state by design.
openPasswordView();
} else {
openLoginView();
}
if (usernameInput) {
usernameInput.focus();
}
}
/* ----------------------------------------------------------- workspace */
function setupWorkspace() {
var main = query('#overview');
if (!main) {
return;
}
api.setSessionLostHandler(function () {
api.forgetLocalState();
global.location.replace('index.html');
});
api.setPasswordChangeHandler(function () {
global.location.replace('index.html#change-password');
});
function renderIdentity(session) {
var user = (session && session.user) || {};
var role = (session && session.role) || user.role || 'member';
var displayName = user.display_name || user.username || '';
var organization = session && session.organization_id ? session.organization_id : null;
var nameNode = query('#rail-display-name');
if (nameNode) {
nameNode.textContent = displayName || 'Unknown account';
}
var roleNode = query('#rail-role');
if (roleNode) {
roleNode.textContent = organization
? api.label(role) + ' · org ' + organization
: api.label(role);
}
var avatar = query('#rail-avatar');
if (avatar) {
avatar.textContent = api.initials(displayName, user.username);
}
var breadcrumb = query('#breadcrumb-identity');
if (breadcrumb) {
breadcrumb.textContent = user.username || displayName || 'Account';
}
var title = query('#workspace-title');
if (title) {
title.textContent = displayName || 'Workspace';
}
var subtitle = query('#workspace-subtitle');
if (subtitle) {
subtitle.textContent =
'Signed in as ' + (user.username || 'this account') +
' with the ' + api.label(role).toLowerCase() +
' role. Project data is not available yet: the backend exposes account, ' +
'organization, and audit endpoints only.';
}
var pill = query('#workspace-session-pill');
if (pill) {
pill.className = 'status-pill status-pill--success';
pill.textContent = 'Session active';
}
var isAdmin = role === 'platform_admin' || role === 'organization_admin';
['#workspace-admin-link', '#workspace-admin-topbar', '#workspace-admin-hero'].forEach(
function (selector) {
var node = query(selector);
if (node) {
api.show(node, isAdmin);
}
}
);
}
function renderFailure(message) {
var subtitle = query('#workspace-subtitle');
if (subtitle) {
subtitle.textContent = message;
}
var pill = query('#workspace-session-pill');
if (pill) {
pill.className = 'status-pill status-pill--warning';
pill.textContent = 'Session unavailable';
}
}
// The session cookie is cleared by the server on this route, so the page
// returns to sign-in whether or not the revocation count comes back.
var leaveWorkspace = function () {
global.location.replace('index.html');
};
queryAll('[data-signout]').forEach(function (button) {
button.addEventListener('click', function () {
api.disable(button, true);
api.logout().then(leaveWorkspace, leaveWorkspace);
});
});
api.me().then(renderIdentity, function (error) {
// A 401 and a 403 password_change_required are both already being
// handled by the redirect handlers above. This covers anything else.
if (error && error.status === 401) {
return;
}
showToast(button.dataset.demoAction);
if (error && error.code === 'password_change_required') {
return;
}
renderFailure(api.describeError(error));
});
});
}
}
setupLogin();
setupWorkspace();
setupSignIn();
setupWorkspace();
})(window);

View File

@@ -26,6 +26,9 @@
}
* { box-sizing: border-box; }
/* Author `display` rules outrank the user-agent `[hidden]` rule, and many
components here set `display`, so hiding must be stated explicitly. */
[hidden] { display: none !important; }
html { scroll-behavior: smooth; }
body { margin: 0; }
a { color: inherit; }
@@ -191,11 +194,146 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
/* Modal and toast */
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 1rem; background: rgba(2, 6, 23, .78); backdrop-filter: blur(7px); }.modal-backdrop[hidden] { display: none; }.modal { width: min(100%, 32rem); max-height: calc(100vh - 2rem); overflow: auto; padding: 1.3rem; background: var(--admin-panel); border: 1px solid var(--admin-border-bright); border-radius: .8rem; box-shadow: var(--shadow); }.modal-heading { display: flex; justify-content: space-between; gap: 1rem; }.modal-heading h2 { margin: 0; color: #fff; }.modal-close { width: 2rem; height: 2rem; color: #94a3b8; background: transparent; border: 1px solid var(--admin-border); border-radius: .5rem; font-size: 1.2rem; }.modal-notice { margin: 1rem 0; padding: .75rem; color: #c4b5fd; background: rgba(124, 58, 237, .1); border: 1px solid rgba(167, 139, 250, .25); border-radius: .5rem; font-size: .72rem; line-height: 1.55; }.modal-notice strong { color: #fff; }.checkbox-row { display: flex !important; align-items: center; gap: .5rem; margin-top: .8rem; color: #cbd5e1 !important; font-weight: 500 !important; }.checkbox-row input { width: auto; min-height: auto; }.modal-actions { display: flex; justify-content: end; gap: .6rem; margin-top: 1.2rem; }.toast { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 30; max-width: min(26rem, calc(100% - 2rem)); padding: .8rem 1rem; color: #e0e7ff; background: #1e293b; border: 1px solid #475569; border-radius: .6rem; box-shadow: var(--shadow); font-size: .75rem; opacity: 0; transform: translateY(.6rem); pointer-events: none; transition: opacity .2s ease, transform .2s ease; }.toast--visible { opacity: 1; transform: translateY(0); }
/* ========================================================================
Real application components.
Everything above this line is the original prototype design system. The
rules below extend it for API-driven pages: view panels, filter toolbars,
tables with row actions, pagination, empty and loading states, the
one-time-password reveal panel, and the form controls the new dialogs use.
No `@import`, no remote font, no inline style: the page ships a single
stylesheet that the Content-Security-Policy allows.
======================================================================== */
/* Focus is always visible for every interactive surface, including the ones
added with the API-driven pages. */
textarea:focus-visible, [tabindex]:focus-visible, .password-toggle:focus-visible,
.modal-close:focus-visible, .icon-button:focus-visible, .admin-nav-link:focus-visible,
.rail-link:focus-visible, summary:focus-visible, .otp-value:focus-visible {
outline: 3px solid rgba(45, 212, 191, .55);
outline-offset: 2px;
}
html.modal-open { overflow: hidden; }
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* View panels: the console is one document with four switchable sections. */
.admin-view[hidden] { display: none; }
.admin-nav { display: grid; gap: .2rem; }
.admin-service-line { display: flex; align-items: center; gap: .5rem; margin: 0 0 .3rem; color: #cbd5e1; }
.admin-user-detail { display: grid; gap: .1rem; }
.admin-user-detail strong { color: #e2e8f0; font-size: .76rem; }
.admin-user-detail small { color: #64748b; font-size: .66rem; }
.status-dot--warning { background: var(--admin-amber); box-shadow: 0 0 0 .25rem rgba(245, 158, 11, .14); }
.admin-card + .admin-card { margin-top: 1rem; }
.admin-view > .admin-card { margin-bottom: 1rem; }
.loading-note { margin: 0; padding: .6rem 0; color: var(--admin-muted); font-size: .74rem; }
[aria-busy="true"] { opacity: .7; }
/* Filter and toolbar rows */
.toolbar { display: flex; flex-wrap: wrap; align-items: end; gap: .7rem; margin-bottom: 1.1rem; padding-bottom: 1.1rem; border-bottom: 1px solid var(--admin-border); }
.filter-field { display: grid; gap: .3rem; min-width: 9rem; }
.filter-field label { color: var(--admin-muted); font-size: .66rem; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; }
.filter-field select { min-height: 2.35rem; padding: .4rem .6rem; color: var(--admin-text); background: #0f172a; border: 1px solid var(--admin-border-bright); border-radius: .45rem; font-size: .76rem; }
.filter-field select:focus { border-color: var(--admin-purple); outline: none; box-shadow: 0 0 0 3px rgba(124, 58, 237, .18); }
.toolbar-actions { display: flex; gap: .5rem; margin-left: auto; }
.toolbar-actions .button { min-height: 2.35rem; }
/* Tables */
.admin-table--wide { min-width: 60rem; }
.admin-table--audit { min-width: 78rem; }
.admin-table td { vertical-align: middle; }
.admin-table th[scope="col"] { white-space: nowrap; }
.table-row--state:hover { background: transparent; }
.table-state { padding: 1.4rem .6rem !important; text-align: center; white-space: normal; }
.table-state .empty-state { border: 0; background: none; padding: 0; }
.count { color: #f8fafc; font-size: .84rem; }
.cell-stack { display: grid; gap: .28rem; justify-items: start; }
.cell-note { color: #64748b; font-size: .66rem; white-space: normal; }
.slug, .action-code, .id-code { color: #c4b5fd; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .68rem; }
.id-code { color: #7dd3fc; }
.row-actions { display: flex; flex-wrap: wrap; gap: .3rem; }
.row-actions .button { min-height: 1.85rem; padding: .25rem .5rem; font-size: .66rem; }
.button[disabled] { cursor: not-allowed; opacity: .45; }
.button[disabled]:hover { transform: none; }
/* Pagination */
.pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .7rem; margin-top: .9rem; padding-top: .9rem; border-top: 1px solid var(--admin-border); }
.pagination__info { color: var(--admin-muted); font-size: .72rem; }
.pagination__buttons { display: flex; gap: .45rem; }
.pagination__buttons .button { min-height: 2.1rem; padding: .35rem .7rem; font-size: .7rem; }
/* Empty states, used by tables and by the whole workspace page */
.empty-state { display: grid; justify-items: start; gap: .4rem; margin-top: .9rem; padding: 1.1rem; background: #101a30; border: 1px dashed #2c3c5c; border-radius: .6rem; }
.empty-state__icon { display: grid; place-items: center; width: 2rem; height: 2rem; color: #5eead4; background: #0f2a35; border-radius: .5rem; font-size: .9rem; }
.empty-state__title { margin: 0; color: #e2e8f0; font-size: .8rem; font-weight: 750; }
.empty-state__body { max-width: 46rem; margin: 0; color: #7c8db0; font-size: .73rem; line-height: 1.6; }
.empty-state .button { margin-top: .4rem; }
#summary-grid .empty-state { grid-column: 1 / -1; }
/* Audit: recent list on the dashboard, sanitized detail on the full table */
.audit-list-item { display: flex; align-items: start; gap: .6rem; padding: .5rem 0; border-top: 1px solid #26334f; }
.audit-list-item:first-child { border-top: 0; padding-top: 0; }
.audit-list-body { display: grid; gap: .18rem; min-width: 0; }
.audit-list-title { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem; margin: 0; }
.audit-list-title strong { color: #e2e8f0; font-size: .76rem; }
.audit-list-body small { color: #64748b; font-size: .66rem; }
.detail-list { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .2rem .55rem; margin: 0; max-width: 26rem; }
.detail-list dt { color: #7c8db0; font-size: .66rem; white-space: nowrap; }
.detail-list dd { margin: 0; color: #cbd5e1; font-size: .68rem; overflow-wrap: anywhere; }
/* One-time password reveal */
.otp-warning { margin: 1rem 0 0; padding: .8rem .9rem; color: #fde68a; background: rgba(120, 53, 15, .3); border: 1px solid rgba(245, 158, 11, .45); border-radius: .5rem; font-size: .73rem; line-height: 1.6; }
.otp-warning strong { color: #fff; }
.otp-panel { margin: .9rem 0; padding: 1rem; background: #0b1226; border: 1px solid var(--admin-purple); border-radius: .6rem; box-shadow: 0 0 0 3px rgba(124, 58, 237, .12); }
.otp-panel-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .6rem; margin-bottom: .7rem; }
.otp-panel-target { color: var(--admin-muted); font-size: .74rem; }
.otp-panel-target strong { color: #fff; }
.otp-value-row { display: flex; align-items: stretch; flex-wrap: wrap; gap: .6rem; }
.otp-value { flex: 1 1 12rem; min-width: 0; padding: .75rem .8rem; color: #fff; background: #020617; border: 1px solid var(--admin-border-bright); border-radius: .5rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .98rem; letter-spacing: .05em; overflow-wrap: anywhere; user-select: all; }
.otp-hint { min-height: 1.05rem; margin: .55rem 0 0; color: #6ee7b7; font-size: .7rem; line-height: 1.5; }
.otp-notice { margin: 0; color: #64748b; font-size: .7rem; line-height: 1.55; }
.modal--wide { width: min(100%, 40rem); }
/* Form controls shared by the dialogs */
.form-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .7rem; }
.form-field { display: grid; gap: .32rem; align-content: start; }
.form-field > label:first-child { margin-top: .35rem; }
.field-hint { margin: 0; color: #64748b; font-size: .67rem; line-height: 1.5; }
.field-label { margin-top: .35rem; color: #c4b5fd; font-size: .74rem; font-weight: 750; }
.field-optional { color: #64748b; font-weight: 500; text-transform: none; letter-spacing: 0; }
.readonly-value { margin: 0; padding: .55rem .7rem; color: #e2e8f0; background: #0f172a; border: 1px solid var(--admin-border); border-radius: .5rem; font-size: .78rem; overflow-wrap: anywhere; }
.enrollment-fieldset { display: grid; gap: .5rem; margin: .6rem 0 0; padding: .85rem; border: 1px solid var(--admin-border); border-radius: .5rem; }
.enrollment-fieldset legend { padding: 0 .35rem; color: #c4b5fd; font-size: .72rem; font-weight: 800; }
.radio-row { display: flex !important; align-items: start; gap: .6rem; margin-top: 0 !important; padding: .55rem .6rem; color: #cbd5e1 !important; background: #101a30; border: 1px solid #26334f; border-radius: .45rem; font-weight: 500 !important; cursor: pointer; }
.radio-row:hover { border-color: var(--admin-purple); }
.radio-row input { width: auto; min-height: auto; margin-top: .15rem; }
.radio-row span { display: grid; gap: .15rem; }
.radio-row strong { color: #f1f5f9; font-size: .76rem; }
.radio-row small { color: #7c8db0; font-size: .67rem; line-height: 1.5; }
.permission-note { margin: 0 0 1rem; padding: .8rem .9rem; color: #c4b5fd; background: rgba(124, 58, 237, .1); border: 1px solid rgba(167, 139, 250, .28); border-radius: .5rem; font-size: .73rem; line-height: 1.6; }
.permission-note strong { color: #fff; }
/* Sign-in: password reveal control and the policy reminder */
.password-field { display: flex; align-items: stretch; gap: .4rem; }
.stack-form .password-field input { flex: 1; min-width: 0; }
.password-toggle { flex: 0 0 auto; padding: 0 .7rem; color: var(--admin-muted); background: #0f172a; border: 1px solid var(--admin-border-bright); border-radius: .5rem; font-size: .7rem; font-weight: 750; }
.password-toggle:hover { color: var(--admin-text); border-color: var(--admin-purple); }
.password-toggle[aria-pressed="true"] { color: #fff; background: rgba(124, 58, 237, .22); border-color: var(--admin-purple); }
.policy-list { margin: 0 0 1.2rem; padding: .85rem .95rem; background: #0f172a; border: 1px solid var(--admin-border); border-radius: .5rem; }
.policy-title { margin: 0 0 .45rem; color: #c4b5fd; font-size: .68rem; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
.policy-list ul { margin: 0; padding-left: 1.15rem; color: var(--admin-muted); font-size: .73rem; line-height: 1.75; }
.prototype-note--live { color: #94a3b8; }
.topbar-signout { color: var(--workspace-teal); }
@media (max-width: 1000px) {
.auth-shell { grid-template-columns: 1fr; gap: 1.5rem; width: min(38rem, calc(100% - 2rem)); padding: 2rem 0; }.auth-brand { min-height: 0; gap: 3rem; }.brand-copy { margin: 2rem 0; }.brand-copy h1 { max-width: 15ch; font-size: 3.4rem; }.brand-points { margin: 1.5rem 0; }
.admin-stat-grid, .maintenance-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.admin-columns, .admin-columns--lower, .workspace-columns { grid-template-columns: 1fr; }
.workspace-rail { width: 13rem; }.workspace-shell { grid-template-columns: 13rem minmax(0, 1fr); }
.toolbar-actions { margin-left: 0; }
}
@media (max-width: 720px) {
.admin-layout, .workspace-shell { display: block; }.admin-sidebar, .workspace-rail { min-height: auto; border-right: 0; border-bottom: 1px solid var(--admin-border); }.admin-sidebar { display: none; }.workspace-rail { padding: .8rem 1rem; }.rail-section-label, .rail-section-label--spaced, .rail-footer { display: none; }.rail-nav { display: flex; gap: .25rem; overflow-x: auto; }.rail-link { white-space: nowrap; }.rail-link:not(.rail-link--active) { display: none; }.workspace-rail .brand-lockup { margin-bottom: .6rem; }.workspace-topbar { min-height: 3.7rem; }.topbar-admin-link, .status-pill { display: none; }.workspace-hero { display: block; padding: 1.8rem 0 1.2rem; }.hero-actions { margin-top: 1rem; }.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.language-row { grid-template-columns: 2.2rem minmax(0, 1fr) auto; }.language-row .progress-track, .language-row .language-percent { display: none; }.admin-topbar { flex-wrap: wrap; gap: .7rem; }.admin-context { padding-left: 0; border-left: 0; }.admin-top-actions { width: 100%; margin-left: 0; justify-content: space-between; }.admin-heading { display: block; }.admin-heading .button { margin-top: 1rem; }.admin-stat strong { font-size: 1.6rem; }
.form-row { grid-template-columns: 1fr; }.filter-field { min-width: 7rem; flex: 1 1 7rem; }.toolbar-actions { width: 100%; margin-left: 0; }.toolbar-actions .button { flex: 1; }
.admin-table--wide, .admin-table--audit { min-width: 46rem; }.otp-value-row .button { width: 100%; }
.pagination { justify-content: stretch; }.pagination__info { width: 100%; }.pagination__buttons .button { flex: 1; }
}

View File

@@ -25,29 +25,70 @@
<div><span class="point-dot"></span>Shared speaker and voice identity</div>
<div><span class="point-dot"></span>Traceable analysis and delivery lineage</div>
</div>
<p class="prototype-note">Prototype environment · no credentials are transmitted</p>
<p class="prototype-note" id="service-note">Checking service status…</p>
</section>
<section class="auth-card" aria-labelledby="signin-title">
<div class="auth-card-heading">
<span class="status-pill status-pill--info">Secure workspace</span>
<h2 id="signin-title">Sign in to your workspace</h2>
<p>Use your UmbraTrans account. Project access is determined by your organization membership.</p>
<section class="auth-card" aria-label="Account access">
<div id="login-view">
<div class="auth-card-heading">
<span class="status-pill status-pill--info">Secure workspace</span>
<h2 id="signin-title">Sign in to your workspace</h2>
<p>Use your UmbraTrans account name and password. Access is determined by your organization membership and role.</p>
</div>
<form id="login-form" class="stack-form" novalidate>
<label for="login-username">Account name</label>
<input id="login-username" name="username" type="text" autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="Your account name" required>
<label for="login-password">Password</label>
<div class="password-field">
<input id="login-password" name="password" type="password" autocomplete="current-password" placeholder="Enter your password" required>
<button id="toggle-login-password" class="password-toggle" type="button" aria-pressed="false" aria-controls="login-password">Show</button>
</div>
<div id="login-message" class="form-message" role="status" aria-live="polite"></div>
<button id="login-submit" class="button button--primary button--wide" type="submit">Sign in <span aria-hidden="true">→</span></button>
</form>
<div id="admin-entry" class="auth-divider"><span>Administrator?</span></div>
<a id="admin-link" class="admin-link" href="uadmin/"><span>Open control panel</span><span aria-hidden="true">↗</span></a>
<p class="auth-footnote">An account issued a temporary password must replace it at first sign-in before any workspace access is granted.</p>
</div>
<div id="password-view" hidden>
<div class="auth-card-heading">
<span class="status-pill status-pill--warning">Action required</span>
<h2 id="password-title">Choose a new password</h2>
<p id="password-lede">Your temporary password must be replaced before you can continue. This step is mandatory.</p>
</div>
<div class="policy-list" aria-labelledby="policy-title">
<p class="policy-title" id="policy-title">Password policy</p>
<ul>
<li id="policy-length">At least 14 characters.</li>
<li>Must not begin or end with whitespace.</li>
<li id="policy-username">Must not contain your account name.</li>
<li>Must differ from the temporary password you are replacing.</li>
</ul>
</div>
<form id="password-form" class="stack-form" novalidate>
<label for="current-password">Current temporary password</label>
<div class="password-field">
<input id="current-password" name="current_password" type="password" autocomplete="current-password" required>
<button id="toggle-current-password" class="password-toggle" type="button" aria-pressed="false" aria-controls="current-password">Show</button>
</div>
<label for="new-password">New password</label>
<div class="password-field">
<input id="new-password" name="new_password" type="password" autocomplete="new-password" required>
<button id="toggle-new-password" class="password-toggle" type="button" aria-pressed="false" aria-controls="new-password">Show</button>
</div>
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required>
<div id="password-message" class="form-message" role="status" aria-live="polite"></div>
<button id="password-submit" class="button button--primary button--wide" type="submit">Set new password <span aria-hidden="true">→</span></button>
<button id="password-signout" class="button button--ghost button--wide" type="button">Sign out instead</button>
</form>
<p class="auth-footnote">This page cannot be left until the password has been changed. Your session stays open while you do.</p>
</div>
<form id="login-form" class="stack-form" novalidate>
<label for="email">Account email</label>
<input id="email" name="email" type="email" autocomplete="username" placeholder="you@example.com" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="Enter your password" required>
<div id="login-message" class="form-message" role="status" aria-live="polite"></div>
<button class="button button--primary button--wide" type="submit">Sign in <span aria-hidden="true">→</span></button>
<button id="demo-login" class="button button--ghost button--wide" type="button">Use prototype demo account</button>
</form>
<div class="auth-divider"><span>Administrator?</span></div>
<a class="admin-link" href="uadmin/"><span>Open control panel</span><span aria-hidden="true">↗</span></a>
<p class="auth-footnote">First login with an administrator-issued temporary password will require a password change before workspace access.</p>
</section>
</main>
<script src="assets/app.js"></script>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="assets/api.js" defer></script>
<script src="assets/app.js" defer></script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="UmbraTrans administration and management">
<meta name="description" content="UmbraTrans account and organization administration">
<title>Control panel · UmbraTrans</title>
<link rel="icon" type="image/jpeg" href="../assets/umbratrans-icon.jpg">
<link rel="stylesheet" href="../assets/styles.css">
@@ -12,88 +12,444 @@
<div class="admin-shell">
<header class="admin-topbar">
<a class="brand-lockup" href="../index.html"><img class="brand-image" src="../assets/umbratrans-icon.jpg" alt="" aria-hidden="true"><span>UmbraTrans</span></a>
<div class="admin-context"><span class="status-pill status-pill--info">Control panel</span><span class="admin-context-name">System administration</span></div>
<div class="admin-top-actions"><span class="admin-user"><span class="avatar avatar--admin">OP</span> Operator</span><a class="admin-exit" href="../index.html">Exit admin ↗</a></div>
<div class="admin-context"><span class="status-pill status-pill--info">Control panel</span><span class="admin-context-name">Identity administration</span></div>
<div class="admin-top-actions">
<span class="admin-user">
<span class="avatar avatar--admin" id="admin-avatar" aria-hidden="true">··</span>
<span class="admin-user-detail">
<strong id="admin-display-name">Checking session…</strong>
<small id="admin-identity">No session</small>
</span>
</span>
<button class="button button--ghost" type="button" data-signout>Sign out</button>
</div>
</header>
<div class="admin-layout">
<aside class="admin-sidebar" aria-label="Administration navigation">
<div class="admin-nav-label">Overview</div>
<a class="admin-nav-link admin-nav-link--active" href="#dashboard"><span>▦</span>Dashboard</a>
<a class="admin-nav-link" href="#accounts"><span>♙</span>Accounts</a>
<a class="admin-nav-link" href="#projects"><span>◈</span>Projects</a>
<div class="admin-nav-label admin-nav-label--spaced">Operations</div>
<a class="admin-nav-link" href="#maintenance"><span>⚙</span>Maintenance</a>
<a class="admin-nav-link" href="#statistics"><span>⌁</span>Statistics</a>
<a class="admin-nav-link" href="#audit"><span>≡</span>Audit log</a>
<div class="admin-sidebar-footer"><span class="status-dot status-dot--good"></span> Prototype environment<br><small>No production data connected</small></div>
<nav class="admin-nav" aria-label="Sections">
<a class="admin-nav-link admin-nav-link--active" href="#dashboard" data-view-link="dashboard"><span aria-hidden="true">▦</span>Dashboard</a>
<a class="admin-nav-link" href="#accounts" data-view-link="accounts"><span aria-hidden="true">♙</span>Accounts</a>
<a class="admin-nav-link" href="#organizations" data-view-link="organizations"><span aria-hidden="true">◈</span>Organizations</a>
<a class="admin-nav-link" href="#audit" data-view-link="audit"><span aria-hidden="true">≡</span>Audit log</a>
</nav>
<div class="admin-sidebar-footer">
<p class="admin-service-line">
<span class="status-dot" id="admin-health-dot" aria-hidden="true"></span>
<span id="admin-service-note">Checking service status…</span>
</p>
<span id="admin-scope-note">Administrator scope is resolved by the server for every request.</span>
</div>
</aside>
<main class="admin-main" id="dashboard">
<div class="admin-banner"><strong>Prototype control panel</strong><span>Buttons demonstrate the intended management flow. No accounts, passwords, or database records are changed.</span></div>
<section class="admin-heading">
<div><p class="eyebrow">Administration</p><h1>System overview</h1><p>Manage identities, project access, maintenance, and service health.</p></div>
<button class="button button--primary" id="open-account-modal">+ Provision account</button>
</section>
<section class="admin-stat-grid" aria-label="System statistics">
<article class="admin-stat"><span class="admin-stat-label">Active users</span><strong>24</strong><small><span class="trend-up">↑ 3</span> this month</small></article>
<article class="admin-stat"><span class="admin-stat-label">Organizations</span><strong>3</strong><small>2 active · 1 pending</small></article>
<article class="admin-stat"><span class="admin-stat-label">Projects</span><strong>11</strong><small><span class="trend-up">↑ 2</span> in production</small></article>
<article class="admin-stat"><span class="admin-stat-label">Worker health</span><strong class="health-value"><span class="status-dot status-dot--good"></span>Good</strong><small>Last check 2 min ago</small></article>
</section>
<main class="admin-main" id="admin-main">
<!-- Dashboard ---------------------------------------------------- -->
<section class="admin-view" id="view-dashboard" data-view="dashboard">
<div class="admin-heading">
<div>
<p class="eyebrow">Administration</p>
<h1>System overview</h1>
<p>Counts below cover only the organizations and accounts you are authorized to see.</p>
</div>
<button class="button button--primary" type="button" data-open="provision-modal">+ Provision account</button>
</div>
<div class="admin-columns">
<section class="admin-card" id="accounts">
<div class="admin-card-heading"><div><p class="eyebrow">Identity</p><h2>Accounts</h2></div><button class="admin-text-button" data-demo-action="Account filtering is not connected in the prototype">View all →</button></div>
<div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>User</th><th>Organization</th><th>Role</th><th>Status</th><th>Last activity</th></tr></thead><tbody>
<tr><td><div class="table-user"><span class="avatar avatar--small avatar--violet">AM</span><span><strong>Alex Morgan</strong><small>alex@example.com</small></span></div></td><td>Northstar Media</td><td>Project manager</td><td><span class="status-pill status-pill--success">Active</span></td><td>4 min ago</td></tr>
<tr><td><div class="table-user"><span class="avatar avatar--small avatar--blue">SK</span><span><strong>Sam Kline</strong><small>sam@example.com</small></span></div></td><td>Northstar Media</td><td>Translator</td><td><span class="status-pill status-pill--info">MFA enabled</span></td><td>18 min ago</td></tr>
<tr><td><div class="table-user"><span class="avatar avatar--small avatar--amber">JR</span><span><strong>Jamie Reed</strong><small>jamie@example.com</small></span></div></td><td>Harbor House</td><td>Audio director</td><td><span class="status-pill status-pill--warning">Temp password</span></td><td>Never</td></tr>
<tr><td><div class="table-user"><span class="avatar avatar--small avatar--rose">TP</span><span><strong>Taylor Price</strong><small>taylor@example.com</small></span></div></td><td>Harbor House</td><td>Viewer</td><td><span class="status-pill status-pill--muted">Invited</span></td><td>Never</td></tr>
</tbody></table></div>
</section>
<section class="admin-card" id="projects">
<div class="admin-card-heading"><div><p class="eyebrow">Tenancy</p><h2>Projects</h2></div><button class="admin-text-button" data-demo-action="Project management is not connected in the prototype">Manage →</button></div>
<div class="admin-project-list">
<div class="admin-project-row"><span class="project-mark project-mark--purple">DH</span><div><strong>Deal Hunter</strong><small>Northstar Media · 4 locales</small></div><span class="status-pill status-pill--warning">Audio in progress</span></div>
<div class="admin-project-row"><span class="project-mark project-mark--blue">TI</span><div><strong>Treasure Island</strong><small>Northstar Media · 2 locales</small></div><span class="status-pill status-pill--success">Ready for review</span></div>
<div class="admin-project-row"><span class="project-mark project-mark--teal">LD</span><div><strong>Last Dwarf</strong><small>Harbor House · 1 locale</small></div><span class="status-pill status-pill--muted">Draft</span></div>
<section class="admin-card" aria-labelledby="summary-title">
<div class="admin-card-heading">
<div><p class="eyebrow">Totals</p><h2 id="summary-title">Account and organization summary</h2></div>
<button class="admin-text-button" type="button" data-refresh="summary">Refresh</button>
</div>
<div class="admin-stat-grid" id="summary-grid" aria-busy="true">
<p class="loading-note">Loading summary…</p>
</div>
</section>
</div>
<section class="admin-card admin-card--maintenance" id="maintenance">
<div class="admin-card-heading"><div><p class="eyebrow">Operations</p><h2>Maintenance</h2></div><span class="status-pill status-pill--success">All systems nominal</span></div>
<div class="maintenance-grid">
<div class="maintenance-item"><span class="maintenance-icon maintenance-icon--green">✓</span><div><strong>Audio workers</strong><small>3 online · 0 stalled</small></div><button class="admin-text-button" data-demo-action="Worker controls are not connected in the prototype">Inspect</button></div>
<div class="maintenance-item"><span class="maintenance-icon maintenance-icon--purple">↻</span><div><strong>Background jobs</strong><small>42 queued · 3 retrying</small></div><button class="admin-text-button" data-demo-action="Queue controls are not connected in the prototype">Inspect</button></div>
<div class="maintenance-item"><span class="maintenance-icon maintenance-icon--blue">◫</span><div><strong>Storage</strong><small>Object storage connected</small></div><button class="admin-text-button" data-demo-action="Storage controls are not connected in the prototype">Inspect</button></div>
<div class="maintenance-item"><span class="maintenance-icon maintenance-icon--amber">!</span><div><strong>Password policy</strong><small>2 temporary accounts pending first login</small></div><button class="admin-text-button" data-demo-action="Account policy is not connected in the prototype">Review</button></div>
</div>
<section class="admin-card" aria-labelledby="recent-title">
<div class="admin-card-heading">
<div><p class="eyebrow">Traceability</p><h2 id="recent-title">Recent activity</h2></div>
<button class="admin-text-button" type="button" data-view-link="audit">Open full audit log →</button>
</div>
<div class="audit-list" id="dashboard-activity" aria-busy="true">
<p class="loading-note">Loading recent events…</p>
</div>
</section>
</section>
<section class="admin-columns admin-columns--lower" id="statistics">
<section class="admin-card"><div class="admin-card-heading"><div><p class="eyebrow">Usage</p><h2>Production activity</h2></div><span class="muted-label">Last 30 days</span></div><div class="admin-chart-placeholder"><div class="chart-bars"><i style="height:38%"></i><i style="height:52%"></i><i style="height:46%"></i><i style="height:68%"></i><i style="height:58%"></i><i style="height:82%"></i><i style="height:72%"></i><i style="height:94%"></i><i style="height:78%"></i><i style="height:88%"></i><i style="height:64%"></i><i style="height:76%"></i></div><div class="chart-axis"><span>Aug 26</span><span>Sep 09</span><span>Sep 26</span></div></div></section>
<section class="admin-card" id="audit"><div class="admin-card-heading"><div><p class="eyebrow">Traceability</p><h2>Recent audit events</h2></div><button class="admin-text-button" data-demo-action="Audit export is not connected in the prototype">Export →</button></div><div class="audit-list"><div><span class="audit-dot audit-dot--purple"></span><p><strong>Project access updated</strong><small>Alex Morgan · 4 min ago</small></p></div><div><span class="audit-dot audit-dot--blue"></span><p><strong>Translation batch approved</strong><small>Sam Kline · 18 min ago</small></p></div><div><span class="audit-dot audit-dot--amber"></span><p><strong>Temporary account provisioned</strong><small>Operator · 1 hr ago</small></p></div></div></section>
<!-- Accounts ----------------------------------------------------- -->
<section class="admin-view" id="view-accounts" data-view="accounts" hidden>
<div class="admin-heading">
<div>
<p class="eyebrow">Identity</p>
<h1>Accounts</h1>
<p>Every account inside your administrative scope, with credential and session state.</p>
</div>
<button class="button button--primary" type="button" data-open="provision-modal">+ Provision account</button>
</div>
<section class="admin-card">
<form class="toolbar" id="account-filters" novalidate>
<div class="filter-field">
<label for="filter-account-status">Status</label>
<select id="filter-account-status" name="status">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="deactivated">Deactivated</option>
</select>
</div>
<div class="filter-field">
<label for="filter-account-role">Role</label>
<select id="filter-account-role" name="role">
<option value="">All roles</option>
<option value="platform_admin">Platform administrator</option>
<option value="organization_admin">Organization administrator</option>
<option value="member">Member</option>
</select>
</div>
<div class="filter-field">
<label for="filter-account-organization">Organization</label>
<select id="filter-account-organization" name="organization_id">
<option value="">All organizations</option>
</select>
</div>
<div class="filter-field">
<label for="filter-account-size">Rows</label>
<select id="filter-account-size" name="limit">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</div>
<div class="toolbar-actions">
<button class="button button--ghost" type="button" id="filter-account-reset">Reset</button>
</div>
</form>
<div class="admin-table-wrap">
<table class="admin-table admin-table--wide">
<caption class="visually-hidden">Accounts in your administrative scope</caption>
<thead>
<tr>
<th scope="col">Account</th>
<th scope="col">Organization</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col">Credential</th>
<th scope="col">Sessions</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="accounts-body"></tbody>
</table>
</div>
<div class="pagination" id="accounts-pagination"></div>
</section>
</section>
<!-- Organizations ------------------------------------------------ -->
<section class="admin-view" id="view-organizations" data-view="organizations" hidden>
<div class="admin-heading">
<div>
<p class="eyebrow">Tenancy</p>
<h1>Organizations</h1>
<p>Tenants that own accounts. Archiving an organization is terminal and cannot be undone.</p>
</div>
<button class="button button--primary" type="button" id="open-organization-modal" data-open="organization-modal">+ New organization</button>
</div>
<p class="permission-note" id="organization-readonly-note" hidden>
<strong>Read-only.</strong> Creating, renaming, re-slugging, and changing the status of an organization are platform-administrator actions. As an organization administrator you can review the tenants you belong to but not change them; the server would refuse the request.
</p>
<section class="admin-card">
<form class="toolbar" id="organization-filters" novalidate>
<div class="filter-field">
<label for="filter-organization-status">Status</label>
<select id="filter-organization-status" name="status">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="archived">Archived</option>
</select>
</div>
<div class="filter-field">
<label for="filter-organization-size">Rows</label>
<select id="filter-organization-size" name="limit">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</div>
<div class="toolbar-actions">
<button class="button button--ghost" type="button" id="filter-organization-reset">Reset</button>
</div>
</form>
<div class="admin-table-wrap">
<table class="admin-table admin-table--wide">
<caption class="visually-hidden">Organizations in your administrative scope</caption>
<thead>
<tr>
<th scope="col">Organization</th>
<th scope="col">Slug</th>
<th scope="col">Status</th>
<th scope="col">Members</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="organizations-body"></tbody>
</table>
</div>
<div class="pagination" id="organizations-pagination"></div>
</section>
</section>
<!-- Audit log ---------------------------------------------------- -->
<section class="admin-view" id="view-audit" data-view="audit" hidden>
<div class="admin-heading">
<div>
<p class="eyebrow">Traceability</p>
<h1>Audit log</h1>
<p>Sanitized identity events. Detail values come from the server verbatim and are rendered as text.</p>
</div>
</div>
<section class="admin-card">
<form class="toolbar" id="audit-filters" novalidate>
<div class="filter-field">
<label for="filter-audit-target">Target account</label>
<select id="filter-audit-target" name="target_user_id">
<option value="">All accounts</option>
</select>
</div>
<div class="filter-field">
<label for="filter-audit-size">Rows</label>
<select id="filter-audit-size" name="limit">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</div>
<div class="toolbar-actions">
<button class="button button--ghost" type="button" id="filter-audit-reset">Reset</button>
</div>
</form>
<div class="admin-table-wrap">
<table class="admin-table admin-table--audit">
<caption class="visually-hidden">Audit events for your administrative scope</caption>
<thead>
<tr>
<th scope="col">Occurred</th>
<th scope="col">Action</th>
<th scope="col">Outcome</th>
<th scope="col">Actor</th>
<th scope="col">Target</th>
<th scope="col">Request</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody id="audit-body"></tbody>
</table>
</div>
<div class="pagination" id="audit-pagination"></div>
</section>
</section>
</main>
</div>
</div>
<div class="modal-backdrop" id="account-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="account-modal-title">
<div class="modal-heading"><div><p class="eyebrow">Identity</p><h2 id="account-modal-title">Provision an account</h2></div><button class="modal-close" id="close-account-modal" aria-label="Close">×</button></div>
<div class="modal-notice"><strong>Prototype only.</strong> A real implementation would issue either a one-time temporary password or an OIDC enrollment invitation. The temporary password is never displayed again and must be changed at first login.</div>
<form id="account-form" class="stack-form">
<label for="account-name">Full name</label><input id="account-name" type="text" placeholder="Taylor Price" required>
<label for="account-email">Email address</label><input id="account-email" type="email" placeholder="taylor@example.com" required>
<label for="account-org">Organization</label><select id="account-org"><option>Northstar Media</option><option>Harbor House</option></select>
<label for="account-role">Initial role</label><select id="account-role"><option>Viewer</option><option>Translator</option><option>Audio director</option><option>Project manager</option></select>
<label class="checkbox-row"><input id="oidc-enrollment" type="checkbox"> Use OIDC enrollment instead of a temporary password</label>
<div class="modal-actions"><button type="button" class="button button--ghost" id="cancel-account-modal">Cancel</button><button type="submit" class="button button--primary">Create prototype account</button></div>
<!-- Provision account ------------------------------------------------- -->
<div class="modal-backdrop" id="provision-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="provision-title">
<div class="modal-heading">
<div><p class="eyebrow">Identity</p><h2 id="provision-title">Provision an account</h2></div>
<button class="modal-close" type="button" data-close="provision-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice">
<strong>Temporary passwords are shown once.</strong> The service generates the value; it is never displayed again, cannot be recovered, and the new account must replace it at first sign-in. OIDC enrollment stores no local password.
</p>
<form id="provision-form" class="stack-form" novalidate>
<div class="form-row">
<div class="form-field">
<label for="provision-username">Account name</label>
<input id="provision-username" name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" maxlength="64" required>
<p class="field-hint" id="provision-username-hint">The name used at sign-in, not an email address.</p>
</div>
<div class="form-field">
<label for="provision-display-name">Display name</label>
<input id="provision-display-name" name="display_name" type="text" autocomplete="off" maxlength="120" required>
</div>
</div>
<div class="form-field">
<label for="provision-email">Email address <span class="field-optional">optional</span></label>
<input id="provision-email" name="email" type="email" autocomplete="off" maxlength="254">
</div>
<div class="form-row">
<div class="form-field">
<label for="provision-organization">Organization</label>
<select id="provision-organization" name="organization_id"></select>
</div>
<div class="form-field">
<label for="provision-role">Role</label>
<select id="provision-role" name="role"></select>
</div>
</div>
<fieldset class="enrollment-fieldset">
<legend>Enrollment mode</legend>
<label class="radio-row" for="provision-mode-temporary">
<input type="radio" id="provision-mode-temporary" name="enrollment_mode" value="temporary_password" checked>
<span><strong>Temporary password</strong><small>Generates a one-time password to hand over out of band.</small></span>
</label>
<label class="radio-row" for="provision-mode-oidc">
<input type="radio" id="provision-mode-oidc" name="enrollment_mode" value="oidc">
<span><strong>OIDC</strong><small>No local password is stored; the identity provider signs the user in.</small></span>
</label>
</fieldset>
<div id="provision-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--ghost" type="button" data-close="provision-modal">Cancel</button>
<button class="button button--primary" type="submit" id="provision-submit">Provision account</button>
</div>
</form>
</section>
</div>
<!-- Account status ---------------------------------------------------- -->
<div class="modal-backdrop" id="account-status-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="account-status-title">
<div class="modal-heading">
<div><p class="eyebrow">Account status</p><h2 id="account-status-title">Change account status</h2></div>
<button class="modal-close" type="button" data-close="account-status-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice" id="account-status-notice">Confirm the change. Leaving <strong>active</strong> revokes every live session for that account immediately.</p>
<form id="account-status-form" class="stack-form" novalidate>
<div class="form-field">
<span class="field-label" id="account-status-target-label">Account</span>
<p class="readonly-value" id="account-status-target">—</p>
</div>
<div class="form-field">
<label for="account-status-value">New status</label>
<select id="account-status-value" name="status"></select>
</div>
<div class="form-field">
<label for="account-status-reason">Reason <span class="field-optional">optional, recorded in the audit log</span></label>
<input id="account-status-reason" name="reason" type="text" maxlength="200" autocomplete="off">
</div>
<div id="account-status-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--ghost" type="button" data-close="account-status-modal">Cancel</button>
<button class="button button--primary" type="submit" id="account-status-submit">Apply status</button>
</div>
</form>
</section>
</div>
<!-- Revoke sessions --------------------------------------------------- -->
<div class="modal-backdrop" id="revoke-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="revoke-title">
<div class="modal-heading">
<div><p class="eyebrow">Sessions</p><h2 id="revoke-title">Revoke active sessions</h2></div>
<button class="modal-close" type="button" data-close="revoke-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice" id="revoke-notice">Every live session for this account is revoked. The account stays active and can sign in again.</p>
<div id="revoke-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--ghost" type="button" data-close="revoke-modal">Cancel</button>
<button class="button button--primary" type="button" id="revoke-confirm">Revoke sessions</button>
</div>
</section>
</div>
<!-- Organization create / edit ---------------------------------------- -->
<div class="modal-backdrop" id="organization-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="organization-modal-title">
<div class="modal-heading">
<div><p class="eyebrow">Tenancy</p><h2 id="organization-modal-title">New organization</h2></div>
<button class="modal-close" type="button" data-close="organization-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice">Platform administrators only. A slug is a permanent handle: changing it is audited and may break links that reference it.</p>
<form id="organization-form" class="stack-form" novalidate>
<div class="form-field">
<label for="organization-name">Name</label>
<input id="organization-name" name="name" type="text" maxlength="120" autocomplete="off" required>
</div>
<div class="form-field">
<label for="organization-slug">Slug</label>
<input id="organization-slug" name="slug" type="text" maxlength="63" autocomplete="off" spellcheck="false">
<p class="field-hint" id="organization-slug-hint">Leave empty to derive the slug from the name.</p>
</div>
<div id="organization-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--ghost" type="button" data-close="organization-modal">Cancel</button>
<button class="button button--primary" type="submit" id="organization-submit">Create organization</button>
</div>
</form>
</section>
</div>
<!-- Organization status ----------------------------------------------- -->
<div class="modal-backdrop" id="organization-status-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="organization-status-title">
<div class="modal-heading">
<div><p class="eyebrow">Tenancy</p><h2 id="organization-status-title">Change organization status</h2></div>
<button class="modal-close" type="button" data-close="organization-status-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice" id="organization-status-notice">Confirm the change. Leaving <strong>active</strong> revokes the live sessions of every member of this organization.</p>
<form id="organization-status-form" class="stack-form" novalidate>
<div class="form-field">
<span class="field-label" id="organization-status-target-label">Organization</span>
<p class="readonly-value" id="organization-status-target">—</p>
</div>
<div class="form-field">
<label for="organization-status-value">New status</label>
<select id="organization-status-value" name="status"></select>
</div>
<div class="form-field">
<label for="organization-status-reason">Reason <span class="field-optional">optional, recorded in the audit log</span></label>
<input id="organization-status-reason" name="reason" type="text" maxlength="200" autocomplete="off">
</div>
<div id="organization-status-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--ghost" type="button" data-close="organization-status-modal">Cancel</button>
<button class="button button--primary" type="submit" id="organization-status-submit">Apply status</button>
</div>
</form>
</section>
</div>
<!-- One-time password reveal ------------------------------------------ -->
<div class="modal-backdrop" id="otp-modal" hidden>
<section class="modal modal--wide" role="dialog" aria-modal="true" aria-labelledby="otp-title" aria-describedby="otp-warning">
<div class="modal-heading">
<div><p class="eyebrow">One-time credential</p><h2 id="otp-title">Temporary password</h2></div>
<button class="modal-close" type="button" data-close="otp-modal" aria-label="Close and discard the password">×</button>
</div>
<p class="otp-warning" id="otp-warning">
<strong>Shown exactly once.</strong> This value is not stored anywhere and cannot be retrieved again after this panel is closed. The account owner must replace it at first sign-in, and you should hand it over through a separate secure channel.
</p>
<div class="otp-panel">
<div class="otp-panel-head">
<span class="otp-panel-target">Password for <strong id="otp-username">—</strong></span>
<span class="status-pill status-pill--warning">Copy it now</span>
</div>
<div class="otp-value-row">
<code class="otp-value" id="otp-value"></code>
<button class="button button--ghost" type="button" id="otp-copy">Copy to clipboard</button>
</div>
<p class="otp-hint" id="otp-copy-status" role="status" aria-live="polite"></p>
</div>
<p class="otp-notice">UmbraTrans never writes this value to browser storage, the URL, the page title, or a log. Closing the panel erases it from this page.</p>
<div class="modal-actions">
<button class="button button--primary" type="button" data-close="otp-modal">I have copied it — close</button>
</div>
</section>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="../assets/admin.js"></script>
<script src="../assets/api.js" defer></script>
<script src="../assets/admin.js" defer></script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="UmbraTrans project workspace">
<title>Workspace · UmbraTrans</title>
<link rel="icon" type="image/jpeg" href="assets/umbratrans-icon.jpg">
<link rel="stylesheet" href="assets/styles.css">
@@ -16,99 +17,146 @@
</a>
<div class="rail-section-label">Workspace</div>
<nav class="rail-nav">
<a class="rail-link rail-link--active" href="#overview"><span class="rail-icon">⌂</span>Overview</a>
<a class="rail-link" href="#translations"><span class="rail-icon">文</span>Translations</a>
<a class="rail-link" href="#voices"><span class="rail-icon">◉</span>Voices</a>
<a class="rail-link" href="#audio"><span class="rail-icon">▶</span>Audio runs</a>
<a class="rail-link" href="#artifacts"><span class="rail-icon">◇</span>Deliverables</a>
<a class="rail-link rail-link--active" href="#overview"><span class="rail-icon" aria-hidden="true">⌂</span>Overview</a>
<a class="rail-link" href="#translations"><span class="rail-icon" aria-hidden="true">文</span>Translations</a>
<a class="rail-link" href="#voices"><span class="rail-icon" aria-hidden="true">◉</span>Voices</a>
<a class="rail-link" href="#audio"><span class="rail-icon" aria-hidden="true">▶</span>Audio runs</a>
<a class="rail-link" href="#artifacts"><span class="rail-icon" aria-hidden="true">◇</span>Deliverables</a>
</nav>
<div class="rail-section-label rail-section-label--spaced">Manage</div>
<nav class="rail-nav">
<a class="rail-link" href="#activity"><span class="rail-icon">≡</span>Activity</a>
<a class="rail-link" href="uadmin/"><span class="rail-icon">⚙</span>Admin</a>
<a class="rail-link" href="#activity"><span class="rail-icon" aria-hidden="true">≡</span>Activity</a>
<a id="workspace-admin-link" class="rail-link" href="uadmin/" hidden><span class="rail-icon" aria-hidden="true">⚙</span>Admin</a>
</nav>
<div class="rail-footer">
<div class="rail-user"><span class="avatar">AM</span><span><strong>Alex Morgan</strong><small>Project manager</small></span></div>
<button class="text-button" data-demo-action="Signed out of the prototype workspace">Sign out</button>
<div class="rail-user">
<span class="avatar" id="rail-avatar" aria-hidden="true">··</span>
<span>
<strong id="rail-display-name">Loading…</strong>
<small id="rail-role">Checking session</small>
</span>
</div>
<button class="text-button" type="button" data-signout>Sign out</button>
</div>
</aside>
<main class="workspace-main" id="overview">
<header class="workspace-topbar">
<div class="breadcrumb"><span>Workspace</span><span aria-hidden="true">/</span><strong>Deal Hunter</strong></div>
<div class="breadcrumb"><span>Workspace</span><span aria-hidden="true">/</span><strong id="breadcrumb-identity">Loading…</strong></div>
<div class="topbar-actions">
<span class="status-pill status-pill--warning">Prototype data</span>
<button class="icon-button" aria-label="Notifications" data-demo-action="Notifications are not connected in the prototype">♢</button>
<a class="topbar-admin-link" href="uadmin/">Control panel ↗</a>
<span class="status-pill status-pill--info" id="workspace-session-pill">Session active</span>
<a id="workspace-admin-topbar" class="topbar-admin-link" href="uadmin/" hidden>Control panel <span aria-hidden="true">↗</span></a>
<button class="text-button topbar-signout" type="button" data-signout>Sign out</button>
</div>
</header>
<section class="workspace-hero">
<div>
<p class="eyebrow eyebrow--teal">Active project · English master</p>
<h1>Deal Hunter</h1>
<p class="hero-subtitle">Production workspace for the source analysis, translations, voice cast, and audio deliverables.</p>
<p class="eyebrow eyebrow--teal">Translation production workspace</p>
<h1 id="workspace-title">Workspace</h1>
<p class="hero-subtitle" id="workspace-subtitle">Loading your session…</p>
</div>
<div class="hero-actions">
<button class="button button--secondary" data-demo-action="Project settings are not connected in the prototype">Project settings</button>
<button class="button button--teal" data-demo-action="New work item flow is not connected in the prototype">+ New work item</button>
<a class="button button--secondary" id="workspace-admin-hero" href="uadmin/" hidden>Open control panel</a>
</div>
</section>
<section class="pipeline-card" aria-labelledby="pipeline-title">
<section class="pipeline-card" aria-labelledby="release-title">
<div class="section-heading">
<div><p class="eyebrow eyebrow--teal">Production pipeline</p><h2 id="pipeline-title">Release readiness</h2></div>
<span class="muted-label">Last reconciled 4 minutes ago</span>
<div><p class="eyebrow eyebrow--teal">Production pipeline</p><h2 id="release-title">Release readiness</h2></div>
</div>
<div class="pipeline-track">
<div class="pipeline-step pipeline-step--done"><span class="pipeline-number">✓</span><strong>Source</strong><small>Imported</small></div>
<div class="pipeline-line pipeline-line--done"></div>
<div class="pipeline-step pipeline-step--done"><span class="pipeline-number">✓</span><strong>Analysis</strong><small>12 speakers</small></div>
<div class="pipeline-line pipeline-line--done"></div>
<div class="pipeline-step pipeline-step--active"><span class="pipeline-number">2</span><strong>Voices</strong><small>8 of 12 cast</small></div>
<div class="pipeline-line"></div>
<div class="pipeline-step"><span class="pipeline-number">3</span><strong>Audio</strong><small>Not started</small></div>
<div class="pipeline-line"></div>
<div class="pipeline-step"><span class="pipeline-number">4</span><strong>Release</strong><small>Not started</small></div>
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">✓</span>
<p class="empty-state__title">No release data</p>
<p class="empty-state__body">Release stages are derived from project, analysis, voice, and audio state. None of those endpoints exist in the backend yet, so no stage progress is reported.</p>
</div>
</section>
<section class="metric-grid" aria-label="Project statistics">
<article class="metric-card"><span class="metric-label">Target languages</span><strong>4</strong><span class="metric-note">2 in progress</span></article>
<article class="metric-card"><span class="metric-label">Approved speakers</span><strong>12</strong><span class="metric-note metric-note--good">All identities linked</span></article>
<article class="metric-card"><span class="metric-label">Voice cast</span><strong>8</strong><span class="metric-note metric-note--warn">4 need review</span></article>
<article class="metric-card"><span class="metric-label">Open work items</span><strong>17</strong><span class="metric-note">Across 4 locales</span></article>
<article class="metric-card">
<span class="metric-label">Target languages</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note metric-note--warn">No project backend yet</span>
</article>
<article class="metric-card">
<span class="metric-label">Approved speakers</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note">No project backend yet</span>
</article>
<article class="metric-card">
<span class="metric-label">Voice cast</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note">No project backend yet</span>
</article>
<article class="metric-card">
<span class="metric-label">Open work items</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note">No project backend yet</span>
</article>
</section>
<div class="workspace-columns">
<section class="workspace-panel" id="translations">
<div class="section-heading"><div><p class="eyebrow eyebrow--teal">Language lanes</p><h2>Translation progress</h2></div><button class="text-button" data-demo-action="Translation workspace is not connected in the prototype">View all →</button></div>
<div class="language-list">
<div class="language-row"><span class="language-code language-code--source">EN</span><div class="language-name"><strong>English master</strong><small>Source analysis</small></div><div class="progress-track"><span style="width:100%"></span></div><strong class="language-percent">100%</strong><span class="status-pill status-pill--success">Ready</span></div>
<div class="language-row"><span class="language-code">DE</span><div class="language-name"><strong>German</strong><small>Translation and proof</small></div><div class="progress-track"><span style="width:72%"></span></div><strong class="language-percent">72%</strong><span class="status-pill status-pill--info">In review</span></div>
<div class="language-row"><span class="language-code">FR</span><div class="language-name"><strong>French</strong><small>Translation draft</small></div><div class="progress-track"><span style="width:41%"></span></div><strong class="language-percent">41%</strong><span class="status-pill status-pill--warning">In progress</span></div>
<div class="language-row"><span class="language-code">ES</span><div class="language-name"><strong>Spanish</strong><small>Queued for review</small></div><div class="progress-track"><span style="width:18%"></span></div><strong class="language-percent">18%</strong><span class="status-pill status-pill--muted">Queued</span></div>
<div class="section-heading">
<div><p class="eyebrow eyebrow--teal">Language lanes</p><h2>Translations</h2></div>
</div>
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">文</span>
<p class="empty-state__title">No translation data</p>
<p class="empty-state__body">There is no project or translation endpoint in the backend yet, so this panel shows nothing rather than sample data. It will populate once the project and translation services land.</p>
</div>
</section>
<section class="workspace-panel" id="voices">
<div class="section-heading"><div><p class="eyebrow eyebrow--teal">Identity</p><h2>Voice cast</h2></div><button class="text-button" data-demo-action="Voice management is not connected in the prototype">Manage →</button></div>
<div class="cast-summary"><div class="cast-ring"><strong>67%</strong><span>cast</span></div><div><strong>8 of 12 speakers assigned</strong><p>Voice profiles are shared across language lanes after rights review.</p></div></div>
<div class="cast-avatars"><span class="cast-avatar cast-avatar--one">MC</span><span class="cast-avatar cast-avatar--two">JR</span><span class="cast-avatar cast-avatar--three">AL</span><span class="cast-avatar cast-avatar--four">SK</span><span class="cast-avatar cast-avatar--more">+4</span></div>
<div class="section-heading">
<div><p class="eyebrow eyebrow--teal">Identity</p><h2>Voices</h2></div>
</div>
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">◉</span>
<p class="empty-state__title">No voice data</p>
<p class="empty-state__body">Speaker, canonical voice, and voice-cast records have no backend yet. Nothing is displayed here until those endpoints exist.</p>
</div>
</section>
</div>
<section class="workspace-panel" id="audio">
<div class="section-heading"><div><p class="eyebrow eyebrow--teal">Operations</p><h2>Recent activity</h2></div><button class="text-button" data-demo-action="Activity history is not connected in the prototype">Activity log →</button></div>
<div class="activity-list">
<div class="activity-row"><span class="activity-icon activity-icon--teal">✓</span><div><strong>German proofing batch approved</strong><small>Project manager · 18 minutes ago</small></div><span class="status-pill status-pill--success">Complete</span></div>
<div class="activity-row"><span class="activity-icon activity-icon--amber">◉</span><div><strong>Voice cast needs review</strong><small>4 speakers awaiting rights confirmation</small></div><span class="status-pill status-pill--warning">Action</span></div>
<div class="activity-row"><span class="activity-icon activity-icon--blue">↻</span><div><strong>French translation synchronized</strong><small>Translation worker · 42 minutes ago</small></div><span class="status-pill status-pill--info">Synced</span></div>
<div class="section-heading">
<div><p class="eyebrow eyebrow--teal">Operations</p><h2>Audio runs</h2></div>
</div>
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">▶</span>
<p class="empty-state__title">No audio run data</p>
<p class="empty-state__body">Audio workers, queues, and storage have no backend endpoints yet. Maintenance and job state will appear here once they do.</p>
</div>
</section>
<div class="workspace-columns">
<section class="workspace-panel" id="artifacts">
<div class="section-heading">
<div><p class="eyebrow eyebrow--teal">Delivery</p><h2>Deliverables</h2></div>
</div>
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">◇</span>
<p class="empty-state__title">No deliverables</p>
<p class="empty-state__body">Artifact download authorization and release state are not implemented in the backend, so no files or counts are shown.</p>
</div>
</section>
<section class="workspace-panel" id="activity">
<div class="section-heading">
<div><p class="eyebrow eyebrow--teal">Traceability</p><h2>Activity</h2></div>
</div>
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">≡</span>
<p class="empty-state__title">No project activity</p>
<p class="empty-state__body">Only identity events are recorded so far, and those are visible to administrators in the control panel audit log.</p>
</div>
</section>
</div>
</main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="assets/app.js"></script>
<script src="assets/api.js" defer></script>
<script src="assets/app.js" defer></script>
</body>
</html>