Separate login and display identities
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 @@
|
||||
</nav>
|
||||
{#if user}
|
||||
<div class="account">
|
||||
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.username}</a>
|
||||
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -8,8 +8,9 @@ export async function softDeleteAccount(
|
||||
reason: string | null
|
||||
): Promise<boolean> {
|
||||
const user = await (await database.prepare(`
|
||||
SELECT users.username,deleted_accounts.deleted_at
|
||||
FROM users LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
SELECT users.username,user_profiles.display_name,deleted_accounts.deleted_at
|
||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id=?
|
||||
`)).get(userId);
|
||||
if (!user || user.deleted_at) return false;
|
||||
@@ -22,8 +23,13 @@ export async function softDeleteAccount(
|
||||
await (await database.prepare(
|
||||
`UPDATE users SET username=?,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
|
||||
)).run(deletedUsername, userId);
|
||||
await (await database.prepare(
|
||||
`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`
|
||||
)).run(userId, 'Deleted Agent', now);
|
||||
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM oauth_states WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare(
|
||||
@@ -34,8 +40,8 @@ export async function softDeleteAccount(
|
||||
'user',
|
||||
userId,
|
||||
'account.soft_deleted',
|
||||
JSON.stringify({ username: user.username }),
|
||||
JSON.stringify({ username: deletedUsername, reason }),
|
||||
JSON.stringify({ username: user.username, displayName: user.display_name }),
|
||||
JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason }),
|
||||
now
|
||||
);
|
||||
return true;
|
||||
|
||||
@@ -9,6 +9,7 @@ const SESSION_SECONDS = 60 * 60 * 24 * 30;
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
discordLinked: boolean;
|
||||
};
|
||||
@@ -55,11 +56,13 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
||||
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare(`
|
||||
SELECT users.id, users.username, users.role, users.discord_id,
|
||||
SELECT users.id, users.username, COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role, users.discord_id,
|
||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at
|
||||
FROM sessions
|
||||
JOIN users ON users.id = sessions.user_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE sessions.token_hash = ? AND sessions.expires_at > ?
|
||||
@@ -86,6 +89,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
||||
return {
|
||||
id: String(user.id),
|
||||
username: String(user.username),
|
||||
displayName: String(user.display_name),
|
||||
role: String(user.role),
|
||||
discordLinked: typeof user.discord_id === 'string'
|
||||
};
|
||||
|
||||
@@ -9,11 +9,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const [builds, guides, favorites, ratings] = await Promise.all([
|
||||
const [builds, guides, favorites, ratings, connection] = await Promise.all([
|
||||
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
|
||||
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
|
||||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
|
||||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id)
|
||||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
|
||||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id),
|
||||
(await database.prepare(`SELECT users.discord_id,users.password_hash,discord_profiles.username AS discord_username,
|
||||
discord_profiles.display_name,discord_profiles.avatar_hash
|
||||
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id)
|
||||
]);
|
||||
const oauthErrorCode = url.searchParams.get('oauth_error');
|
||||
const oauthErrors: Record<string, { title: string; message: string }> = {
|
||||
@@ -36,20 +39,47 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
message: 'Discord linking could not be completed. Your existing sign-in methods and account data were not changed.'
|
||||
}
|
||||
: null;
|
||||
return { account: { builds, guides, favorites, ratings }, chooseUsername: url.searchParams.has('choose_username'), discordLinked: url.searchParams.has('discord_linked'), oauthError };
|
||||
return {
|
||||
account: { builds, guides, favorites, ratings },
|
||||
discordConnection: connection?.discord_id ? {
|
||||
id: String(connection.discord_id),
|
||||
username: connection.discord_username ? String(connection.discord_username) : null,
|
||||
displayName: connection.display_name ? String(connection.display_name) : null,
|
||||
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
|
||||
canUnlink: typeof connection.password_hash === 'string'
|
||||
} : null,
|
||||
hasPassword: typeof connection?.password_hash === 'string',
|
||||
chooseDisplayName: url.searchParams.has('choose_display_name'),
|
||||
discordLinked: url.searchParams.has('discord_linked'),
|
||||
oauthError
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
changeUsername: async ({ request, cookies }) => {
|
||||
changeLoginUsername: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const username = String((await request.formData()).get('username') ?? '').normalize('NFKC').trim();
|
||||
if (username.length < 3 || username.length > 24 || !/^[\p{L}\p{N}_-]+$/u.test(username)) return fail(400, { usernameError: 'Use 3–24 letters, numbers, underscores, or hyphens.' });
|
||||
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { usernameError: 'That username is reserved.' });
|
||||
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { loginUsernameError: 'Use 3–32 ASCII letters, numbers, underscores, or hyphens.' });
|
||||
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { loginUsernameError: 'That login username is reserved.' });
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { usernameError: 'That username is already in use.' });
|
||||
if (await (await database.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { loginUsernameError: 'That login username is already in use.' });
|
||||
await (await database.prepare('UPDATE users SET username = ? WHERE id = ?')).run(username, user.id);
|
||||
return { usernameChanged: true };
|
||||
return { loginUsernameChanged: true };
|
||||
},
|
||||
changeDisplayName: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const displayName = String((await request.formData()).get('displayName') ?? '').normalize('NFKC').trim();
|
||||
const length = Array.from(displayName).length;
|
||||
if (length < 2 || length > 40 || /[\p{C}\r\n]/u.test(displayName)) {
|
||||
return fail(400, { displayNameError: 'Use 2–40 visible characters on one line.' });
|
||||
}
|
||||
const now = new Date().toISOString(), database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`))
|
||||
.run(user.id, displayName, now);
|
||||
return { displayNameChanged: true };
|
||||
},
|
||||
changePassword: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
@@ -60,7 +90,8 @@ export const actions: Actions = {
|
||||
if (next !== confirm) return fail(400, { passwordError: 'The new passwords do not match.' });
|
||||
const database = await getDatabase();
|
||||
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id = ?')).get(user.id);
|
||||
if (typeof credential?.password_hash !== 'string' || !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' });
|
||||
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' });
|
||||
if (typeof credential?.password_hash !== 'string' && !user.discordLinked) return fail(400, { passwordError: 'A verified sign-in method is required before setting a password.' });
|
||||
await (await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')).run(await hashPassword(next), user.id);
|
||||
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
|
||||
await createSession(cookies, user.id);
|
||||
@@ -83,5 +114,22 @@ export const actions: Actions = {
|
||||
}
|
||||
cookies.delete('shd_session', { path: '/' });
|
||||
redirect(303, '/?account_deleted=1');
|
||||
},
|
||||
unlinkDiscord: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const password = String((await request.formData()).get('password') ?? '');
|
||||
const database = await getDatabase();
|
||||
const credential = await (await database.prepare('SELECT password_hash,discord_id FROM users WHERE id=?')).get(user.id);
|
||||
if (!credential?.discord_id) return fail(409, { discordUnlinkError: 'No Discord account is linked.' });
|
||||
if (typeof credential.password_hash !== 'string') {
|
||||
return fail(400, { discordUnlinkError: 'Add a password sign-in method before unlinking Discord so you do not lose access.' });
|
||||
}
|
||||
if (!(await verifyPassword(credential.password_hash, password))) {
|
||||
return fail(400, { discordUnlinkError: 'Your password is incorrect.' });
|
||||
}
|
||||
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(user.id);
|
||||
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(user.id);
|
||||
return { discordUnlinked: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,21 +4,20 @@
|
||||
const user = $derived(data.user!);
|
||||
let discordLinkPending = $state(false);
|
||||
function beginDiscordLink(event: MouseEvent) {
|
||||
if (discordLinkPending) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (discordLinkPending) { event.preventDefault(); return; }
|
||||
discordLinkPending = true;
|
||||
}
|
||||
</script>
|
||||
<svelte:head><title>{user.username} — SHD.build</title></svelte:head>
|
||||
|
||||
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
|
||||
<main class="page profile-page">
|
||||
<section class="profile-command">
|
||||
<div class="profile-ident"><span class="profile-avatar">{user.username.slice(0,1).toUpperCase()}</span><div><p class="eyebrow"><span></span> AGENT PROFILE</p><h1>{user.username}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
|
||||
<div class="profile-ident"><span class="profile-avatar">{user.displayName.slice(0, 1).toUpperCase()}</span><div><p class="eyebrow"><span></span> AGENT PROFILE</p><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
|
||||
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div></div>
|
||||
</section>
|
||||
{#if data.chooseUsername}<div class="account-alert"><b>IDENTITY SETUP REQUIRED</b><span>Choose the unique SHD.build username other agents will see.</span></div>{/if}
|
||||
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
|
||||
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
|
||||
{#if form?.discordUnlinked}<p class="success">Discord account unlinked. You can continue signing in with your username and password.</p>{/if}
|
||||
{#if data.oauthError}<div class="account-alert error-alert" role="alert"><b>{data.oauthError.title}</b><span>{data.oauthError.message}</span></div>{/if}
|
||||
<div class="profile-dashboard">
|
||||
<section class="profile-feed">
|
||||
@@ -27,6 +26,22 @@
|
||||
</div>
|
||||
<div class="profile-columns"><div class="profile-module"><p class="eyebrow">SAVED INTELLIGENCE</p><h2>FAVORITES</h2><strong class="large-count">{data.account.favorites.length}</strong><span class="count-label">SAVED LOADOUTS</span></div><div class="profile-module"><p class="eyebrow">FIELD FEEDBACK</p><h2>RATINGS</h2><strong class="large-count">{data.account.ratings.length}</strong><span class="count-label">RATED LOADOUTS</span></div></div>
|
||||
</section>
|
||||
<aside class="account-console"><div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div><p class="eyebrow">PUBLIC IDENTITY</p><h2>USERNAME</h2><PendingForm action="?/changeUsername"><label class="field">PUBLIC USERNAME<input name="username" value={user.username} required /></label>{#if form?.usernameError}<p class="error">{form.usernameError}</p>{/if}<button class="primary console-action">UPDATE IDENTITY →</button></PendingForm><hr/><p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2><div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div><hr/><p class="eyebrow">SECURITY</p><h2>CHANGE PASSWORD</h2><PendingForm action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm><hr/><details class="danger-zone"><summary>DELETE ACCOUNT</summary><p>Your builds, guides, and comments remain attributed to a deleted agent. Your username and Discord identity become available again.</p><PendingForm action="?/deleteAccount"><label class="field">TYPE {user.username} TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details></aside>
|
||||
<aside class="account-console">
|
||||
<div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div>
|
||||
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
|
||||
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
|
||||
<hr/><p class="eyebrow">PRIVATE CREDENTIAL</p><h2>LOGIN USERNAME</h2>
|
||||
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.username} autocomplete="username" required /></label><small class="identity-note">Used only to sign in and visible to you and authorized ISAC operators.</small>{#if form?.loginUsernameError}<p class="error">{form.loginUsernameError}</p>{/if}<button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
|
||||
<hr/><p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2>
|
||||
{#if data.discordConnection}
|
||||
<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>{data.discordConnection.displayName ?? data.discordConnection.username ?? 'DISCORD USER'}</b><small>{data.discordConnection.username ? `@${data.discordConnection.username} · ` : ''}ID {data.discordConnection.id}</small></div></div><span class="connection-state">LINKED</span></div>
|
||||
<PendingForm action="?/unlinkDiscord" class="unlink-console"><label class="field">PASSWORD TO UNLINK<input name="password" type="password" autocomplete="current-password" disabled={!data.discordConnection.canUnlink} required={data.discordConnection.canUnlink}/></label>{#if !data.discordConnection.canUnlink}<p class="connection-warning">Discord is your only sign-in method. Set a password below before unlinking.</p>{/if}{#if form?.discordUnlinkError}<p class="error">{form.discordUnlinkError}</p>{/if}<button class="secondary console-action" disabled={!data.discordConnection.canUnlink}>UNLINK DISCORD</button></PendingForm>
|
||||
{:else}
|
||||
<div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div>
|
||||
{/if}
|
||||
<hr/><p class="eyebrow">SECURITY</p><h2>{data.hasPassword ? 'CHANGE PASSWORD' : 'SET PASSWORD'}</h2>
|
||||
<PendingForm action="?/changePassword">{#if data.hasPassword}<label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label>{/if}<label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">{data.hasPassword ? 'UPDATE PASSWORD' : 'ADD PASSWORD SIGN-IN'} →</button></PendingForm>
|
||||
<hr/><details class="danger-zone"><summary>DELETE ACCOUNT</summary><p>Your builds, guides, and comments remain attributed to a deleted agent. Your login username, display name, and Discord identity become available again.</p><PendingForm action="?/deleteAccount"><label class="field">TYPE {user.username} TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -9,18 +9,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const database = await getDatabase();
|
||||
const users = await (await database.prepare(`
|
||||
SELECT users.id,users.username,users.role,users.discord_id,users.created_at,
|
||||
SELECT users.id,users.username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role,users.discord_id,users.created_at,
|
||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at,
|
||||
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
|
||||
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
||||
LIMIT 200
|
||||
`)).all(pattern);
|
||||
`)).all(pattern, pattern);
|
||||
const now = Date.now();
|
||||
return {
|
||||
users: users.map((user) => {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
<div class="module-kpis"><div><span>RESULTS</span><strong>{data.users.length}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.users.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.users.filter((user) => user.inactive).length}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.users.filter((user) => user.accessState !== 'ACTIVE').length}</strong><small>Blocked or banned</small></div></div>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.username}</strong><small>{user.discord_id ? 'DISCORD + LOCAL IDENTITY' : 'LOCAL IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username} · {user.discord_id ? 'DISCORD LINKED' : 'LOCAL IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,15 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const database = await getDatabase();
|
||||
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments] = await Promise.all([
|
||||
(await database.prepare(`SELECT users.id,users.username,users.role,users.discord_id,users.created_at,user_status.*,
|
||||
(await database.prepare(`SELECT users.id,users.username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role,users.discord_id,users.created_at,
|
||||
CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password,
|
||||
discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name,
|
||||
discord_profiles.avatar_hash AS discord_avatar_hash,user_status.*,
|
||||
deleted_accounts.deleted_at,deleted_accounts.deleted_by,deleted_accounts.reason AS deletion_reason
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(params.id),
|
||||
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
||||
@@ -102,5 +108,21 @@ export const actions: Actions = {
|
||||
return fail(409, { adminDeleteError: 'This account has already been deleted.' });
|
||||
}
|
||||
return { accountDeleted: true };
|
||||
},
|
||||
unlinkDiscord: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT discord_id,password_hash FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { identityError: 'User not found.' });
|
||||
if (!target.discord_id) return fail(409, { identityError: 'This user does not have a linked Discord account.' });
|
||||
if (typeof target.password_hash !== 'string') {
|
||||
return fail(400, { identityError: 'Discord is this user’s only sign-in method. They must set a password before it can be unlinked.' });
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(params.id);
|
||||
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'user', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now);
|
||||
return { identityUnlinked: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
const accountState = () => data.profile.deleted_at ? 'DELETED' : data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
|
||||
</script>
|
||||
<svelte:head><title>{data.profile.username} — ISAC user operations</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.username}</h2><p>Identity created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}</p></div><a class="secondary button-link" href="/admin/users">← USER REGISTRY</a></div>
|
||||
<svelte:head><title>{data.profile.display_name} — ISAC user operations</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username} · Created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}</p></div><a class="secondary button-link" href="/admin/users">← USER REGISTRY</a></div>
|
||||
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.builds.length}</strong><small>Recent records loaded</small></div><div><span>COMMENTS</span><strong>{data.comments.length}</strong><small>Recent records loaded</small></div></div>
|
||||
|
||||
<div class="user-console-grid">
|
||||
@@ -28,6 +28,8 @@
|
||||
</section>
|
||||
<section class="dashboard-editor moderation-panel">
|
||||
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
|
||||
<div class="isac-identity"><p class="label">LINKED DISCORD IDENTITY</p>{#if data.profile.discord_id}<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}</b><small>{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}</small></div></div><span class="connection-state">LINKED</span></div>{#if can('users.identities')}<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if !Number(data.profile.has_password)}<p class="connection-warning">Discord is this agent’s only sign-in method. They must set a password before unlinking.</p>{/if}{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}<button class="secondary console-action" disabled={!Number(data.profile.has_password)}>UNLINK DISCORD</button></PendingForm>{/if}{:else}<p class="console-note identity-empty">NO DISCORD IDENTITY LINKED</p>{/if}</div>
|
||||
<hr/>
|
||||
{#if can('users.moderate') && !data.profile.deleted_at}
|
||||
<PendingForm action="?/moderate"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
|
||||
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
|
||||
|
||||
@@ -10,7 +10,16 @@ import type { RequestHandler } from './$types';
|
||||
const STATE_COOKIE = 'shd_discord_oauth_state';
|
||||
|
||||
type DiscordToken = { access_token: string; token_type: string };
|
||||
type DiscordUser = { id: string; username: string; global_name: string | null };
|
||||
type DiscordUser = { id: string; username: string; global_name: string | null; avatar: string | null };
|
||||
|
||||
async function saveDiscordProfile(userId: string, discordUser: DiscordUser): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO discord_profiles(user_id,discord_id,username,display_name,avatar_hash,updated_at)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
|
||||
discord_id=excluded.discord_id,username=excluded.username,display_name=excluded.display_name,
|
||||
avatar_hash=excluded.avatar_hash,updated_at=excluded.updated_at`))
|
||||
.run(userId, discordUser.id, discordUser.username, discordUser.global_name, discordUser.avatar, new Date().toISOString());
|
||||
}
|
||||
|
||||
function stateMatches(expected: string | undefined, received: string | null): boolean {
|
||||
if (!expected || !received) return false;
|
||||
@@ -19,22 +28,14 @@ function stateMatches(expected: string | undefined, received: string | null): bo
|
||||
return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
|
||||
}
|
||||
|
||||
async function uniqueUsername(discordUser: DiscordUser): Promise<string> {
|
||||
async function uniqueUsername(): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
const base =
|
||||
(discordUser.global_name || discordUser.username)
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}_-]/gu, '')
|
||||
.slice(0, 24) || 'Agent';
|
||||
const base = `agent_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
|
||||
const find = await database.prepare('SELECT 1 FROM users WHERE username = ? COLLATE NOCASE');
|
||||
|
||||
if (!(await find.get(base))) return base;
|
||||
|
||||
const suffix = discordUser.id.slice(-6);
|
||||
const candidate = `${base.slice(0, 17)}_${suffix}`;
|
||||
if (!(await find.get(candidate))) return candidate;
|
||||
|
||||
return `Agent_${randomUUID().slice(0, 8)}`;
|
||||
return `agent_${randomUUID().replaceAll('-', '').slice(0, 20)}`;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
|
||||
@@ -101,6 +102,7 @@ export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
|
||||
|
||||
const link = await database.prepare('UPDATE users SET discord_id = ? WHERE id = ?');
|
||||
await link.run(discordUser.id, sessionUser.id);
|
||||
await saveDiscordProfile(sessionUser.id, discordUser);
|
||||
redirect(303, '/account?discord_linked=1');
|
||||
}
|
||||
|
||||
@@ -109,12 +111,15 @@ export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
|
||||
let created = false;
|
||||
|
||||
if (!user) {
|
||||
const username = await uniqueUsername(discordUser);
|
||||
const username = await uniqueUsername();
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const insertUser = await database.prepare(
|
||||
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
await insertUser.run(id, username, discordUser.id, 'user', new Date().toISOString());
|
||||
await insertUser.run(id, username, discordUser.id, 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, discordUser.global_name || discordUser.username, now);
|
||||
user = { id };
|
||||
created = true;
|
||||
}
|
||||
@@ -126,7 +131,8 @@ export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
|
||||
await recordLogin(String(user.id), 'discord', false);
|
||||
redirect(303, '/sign-in?oauth_error=account_blocked');
|
||||
}
|
||||
await saveDiscordProfile(String(user.id), discordUser);
|
||||
await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'discord', true);
|
||||
redirect(303, created ? '/account?choose_username=1' : '/account');
|
||||
redirect(303, created ? '/account?choose_display_name=1' : '/account');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user