Separate login and display identities
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user