diff --git a/database/schema.sql b/database/schema.sql
index aa3416b..0d3cc31 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -1,5 +1,11 @@
-- Initial embedded Turso schema. Every statement is safe to run repeatedly.
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, discord_id TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL);
+CREATE TABLE IF NOT EXISTS user_profiles (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ display_name TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+INSERT OR IGNORE INTO user_profiles(user_id,display_name,updated_at) SELECT id,username,created_at FROM users;
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'draft', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id));
@@ -51,6 +57,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-moderate','users.moderate','Block, ban, and reactivate users');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-roles','users.roles','Assign roles and direct permissions');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-delete','users.delete','Soft-delete user accounts while retaining authored content');
+INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-identities','users.identities','View and manage linked external identities');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
@@ -59,8 +66,17 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-roles');
+INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-identities');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-admin','permission-system-admin');
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
+CREATE TABLE IF NOT EXISTS discord_profiles (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ discord_id TEXT NOT NULL UNIQUE,
+ username TEXT NOT NULL,
+ display_name TEXT,
+ avatar_hash TEXT,
+ updated_at TEXT NOT NULL
+);
CREATE UNIQUE INDEX IF NOT EXISTS users_username_nocase_idx ON users(username COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS stat_attributes (
id TEXT PRIMARY KEY,
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index 4a3c8da..c84cb25 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -62,8 +62,19 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
the left and the actionable secure-access console on the right.
- OAuth providers appear as connection nodes with a recognizable provider color;
SHD orange remains reserved for the primary local action and navigation.
+- Linked identity nodes show provider display name, account handle or stable ID,
+ and an explicit textual `LINKED` state. Unlink controls sit beneath the node,
+ require credential confirmation, and remain disabled with an explanation when
+ unlinking would remove the user's only sign-in method.
+- ISAC user dossiers reuse the same linked-identity node. Administrative
+ unlinking requires a scoped identity-management capability, produces an audit
+ event, and must never remove the user's only available sign-in method.
- On small screens, the split frame becomes a single column and account controls
move before archival content.
+- Public display names and private login usernames are distinct. Display names
+ support normalized Unicode and relaxed visible characters; login usernames
+ remain unique, ASCII-only credentials and must never appear on public-facing
+ community surfaces. Authorized ISAC user operations may show both.
### Third-party identity buttons
diff --git a/src/app.css b/src/app.css
index 42fb050..79ee79e 100644
--- a/src/app.css
+++ b/src/app.css
@@ -63,6 +63,9 @@
@media(max-width:900px){.filter-bar{grid-template-columns:1fr 1fr}.catalogue-hero{align-items:flex-start}.guide-layout,.profile-dashboard,.auth-frame{grid-template-columns:1fr}.profile-command{align-items:flex-start}.profile-metrics{display:grid;grid-template-columns:1fr 1fr}.account-console{order:-1}.auth-intel{border-right:0!important;border-bottom:1px solid #303536!important;padding:42px!important}.auth-intel h1{font-size:52px!important}}@media(max-width:560px){.catalogue-hero,.profile-command{flex-direction:column;align-items:flex-start}.catalogue-count{text-align:left;margin-top:20px}.catalogue-count .primary{justify-self:start}.filter-bar{grid-template-columns:1fr}.guide-meta{position:static;margin-top:25px;flex-wrap:wrap}.profile-metrics{width:100%;margin-top:20px}.profile-columns{grid-template-columns:1fr}.auth-page{padding:25px 14px}.auth-intel,.auth-console{padding:28px!important}}
.discord-connection{display:grid;grid-template-columns:1fr;gap:9px;padding:10px}.connection-copy{display:flex!important;align-items:center;gap:10px}.connection-copy>img{width:28px;height:21px;padding:4px;background:#5865f2;border-radius:3px}.discord-link-button{min-height:42px;padding:8px 14px;display:flex;align-items:center;justify-content:center;gap:12px;background:#5865f2;color:#fff!important;border-radius:4px;font-size:14px!important;font-weight:600!important;letter-spacing:0;text-transform:none}.discord-link-button img{display:block;width:25px;height:19px;object-fit:contain}.discord-link-button:hover{background:#4752c4;filter:none}.discord-link-button:active{background:#3c45a5;filter:none}.discord-link-button:focus-visible{outline:2px solid #e0e3ff;outline-offset:3px}.discord-link-button[aria-busy="true"]::after{content:"";width:11px;height:11px;border:2px solid #fff;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite}
+.linked-connection{grid-template-columns:1fr auto}.connection-state{padding:5px 7px;border:1px solid #3f6a4c;color:#75c08c;font-size:7px;font-weight:bold;letter-spacing:1px}.unlink-console{margin-top:8px}.connection-warning{margin:8px 0;color:#c29a60;font-size:8px;line-height:1.4}
+.isac-identity{margin:10px 0 14px}.isac-identity>.label{margin-bottom:6px}.identity-empty{padding:12px!important;border:1px dashed #343a3b}
+.identity-note{display:block;margin-top:5px;color:#687073;font-size:7px;line-height:1.4}
.brand-name{display:block;max-width:420px;margin:10px 0}.effect-editor{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.effect-band{display:grid;grid-template-columns:58px minmax(0,1fr) 100px;align-items:end;gap:8px;padding:9px;background:#101314;border:1px solid #343a3b}.effect-band>strong{align-self:stretch;display:grid;place-content:center;text-align:center;color:#f6a821;font-size:23px;border-right:1px solid #343a3b}.effect-band>strong small{display:block;color:#707779;font-size:6px;letter-spacing:1px}.effect-band .field{margin:0}.split-actions{justify-content:space-between}.split-actions>div{display:flex;gap:8px}.brand-set-row{grid-template-columns:1.1fr 1.2fr 1.2fr 1.2fr .6fr .3fr}.empty-dashboard{min-height:120px;display:grid;place-content:center;text-align:center;gap:6px;color:#626a6c}.empty-dashboard strong{font-size:10px;letter-spacing:1.2px}.empty-dashboard span{font-size:8px}
.gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:8px;font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:7px;color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:8px;letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:9px}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:6px;color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:8px;color:#858b8d}
diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte
index edb640c..6da83d9 100644
--- a/src/lib/components/AppShell.svelte
+++ b/src/lib/components/AppShell.svelte
@@ -4,7 +4,7 @@
import type { Snippet } from 'svelte';
let { user, isacAccess, children }: {
- user: { username: string; role: string } | null;
+ user: { username: string; displayName: string; role: string } | null;
isacAccess: boolean;
children: Snippet;
} = $props();
@@ -33,7 +33,7 @@
{#if user}