Add login security retention and legal terms
This commit is contained in:
@@ -42,6 +42,21 @@ CREATE TABLE IF NOT EXISTS login_events (
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
||||
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
client_name TEXT,
|
||||
operating_system TEXT,
|
||||
device_type TEXT,
|
||||
location_country TEXT,
|
||||
location_region TEXT,
|
||||
location_city TEXT,
|
||||
failure_reason TEXT,
|
||||
risk_flags TEXT,
|
||||
credential_id TEXT,
|
||||
session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
|
||||
session_ended_at TEXT,
|
||||
session_end_reason TEXT,
|
||||
sensitive_purged_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_emails (
|
||||
|
||||
@@ -155,6 +155,17 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
|
||||
an account through verified email, OAuth, or local credentials, then enroll a
|
||||
passkey from Account Access.
|
||||
|
||||
### Footer and legal surfaces
|
||||
|
||||
- Every routed page ends with the global SHD footer containing Terms & Privacy,
|
||||
data-retention access, and the unofficial Ubisoft fan-site disclaimer.
|
||||
- Legal pages retain the command-console visual language but prioritize readable
|
||||
serif body copy, anchored sections, and restrained line length.
|
||||
- Login security metadata is operationally visible in ISAC for 30 days. After
|
||||
that period, IP, user-agent, normalized client/device fields, and credential
|
||||
references and proxy-supplied approximate location are scrubbed while
|
||||
non-sensitive event facts, categorical risk flags, and session outcomes may remain.
|
||||
|
||||
### Admin dashboard
|
||||
|
||||
- Use `admin-page`, `admin-command`, `admin-dashboard`, `admin-sidebar`, and `admin-content`.
|
||||
|
||||
@@ -58,6 +58,28 @@ if (typeof loginEventsDefinition?.sql === 'string' && !loginEventsDefinition.sql
|
||||
await database.exec('DROP TABLE login_events_legacy');
|
||||
await database.exec('CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)');
|
||||
}
|
||||
const loginEventColumns = await (await database.prepare("PRAGMA table_info('login_events')")).all();
|
||||
for (const [name, definition] of [
|
||||
['ip_address', 'TEXT'],
|
||||
['user_agent', 'TEXT'],
|
||||
['client_name', 'TEXT'],
|
||||
['operating_system', 'TEXT'],
|
||||
['device_type', 'TEXT'],
|
||||
['location_country', 'TEXT'],
|
||||
['location_region', 'TEXT'],
|
||||
['location_city', 'TEXT'],
|
||||
['failure_reason', 'TEXT'],
|
||||
['risk_flags', 'TEXT'],
|
||||
['credential_id', 'TEXT'],
|
||||
['session_id', 'TEXT REFERENCES sessions(id) ON DELETE SET NULL'],
|
||||
['session_ended_at', 'TEXT'],
|
||||
['session_end_reason', 'TEXT'],
|
||||
['sensitive_purged_at', 'TEXT']
|
||||
]) {
|
||||
if (!loginEventColumns.some((column) => column.name === name)) {
|
||||
await database.exec(`ALTER TABLE login_events ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
@media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}}
|
||||
.isac-operation-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:10px 0 0;border:1px solid var(--shd-border);background:#0d1011}.isac-operation-tabs button{height:40px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.isac-operation-tabs button:first-child{border-left:0}.isac-operation-tabs button b{margin-left:5px;color:var(--shd-success)}.isac-operation-tabs button:hover,.isac-operation-tabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.isac-operation-tabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.isac-operation-tabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.user-console-grid{grid-template-columns:minmax(0,1fr)}.isac-panel-hidden{display:none!important}.user-history-registries{display:grid;gap:10px;margin-top:10px}.history-registry{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.history-filter{display:grid;grid-template-columns:minmax(180px,1fr) 170px 80px;gap:6px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}.history-filter input,.history-filter select{height:34px;margin:0;padding:0 9px;font-size:8px}.history-filter button{height:34px;padding:0 9px;font-size:7px}.history-registry:last-child .history-filter{grid-template-columns:minmax(180px,1fr) 80px}.history-registry .registry-pagination{margin:0;border-width:0 0 1px}.history-registry .history-list{max-height:none}.history-registry .comment-history>div{grid-template-columns:minmax(140px,.5fr) minmax(260px,1.5fr) auto}.history-registry .comment-history span{white-space:normal;line-height:1.4}@media(max-width:650px){.isac-operation-tabs,.history-filter,.history-registry:last-child .history-filter{grid-template-columns:1fr}.isac-operation-tabs button{border-left:0;border-top:1px solid var(--shd-border)}.isac-operation-tabs button:first-child{border-top:0}}
|
||||
.isac-modal-backdrop{position:fixed;z-index:50;inset:0;display:grid;place-items:center;padding:20px;background:#050607d9;backdrop-filter:blur(3px)}.isac-confirm-modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:16px;border:1px solid #5a6061;border-top:2px solid var(--shd-orange);background:#131617;box-shadow:0 24px 80px #000}.access-confirm-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.access-confirm-grid>div{min-height:120px;align-content:start;display:grid;gap:5px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.access-confirm-grid>div>span{margin-bottom:3px;color:var(--shd-text-dim);font-size:7px;letter-spacing:1px}.access-confirm-grid b{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 7px;border-left:2px solid #596164;background:#151819;color:#aeb3b4;font-size:8px}.access-confirm-grid b span{color:inherit}.access-confirm-grid b em{font-style:normal;color:#747c7e;font-size:6px;letter-spacing:.8px}.access-confirm-grid b.access-add{border-left-color:var(--shd-success);background:#101713;color:#9bd4ac}.access-confirm-grid b.access-add em{color:var(--shd-success)}.access-confirm-grid b.access-remove{border-left-color:var(--shd-danger);background:#211414;color:#dca096;text-decoration:line-through}.access-confirm-grid b.access-remove em{color:var(--shd-danger);text-decoration:none}.access-confirm-grid small{color:var(--shd-text-dim);font-size:7px}@media(max-width:560px){.access-confirm-grid{grid-template-columns:1fr}}
|
||||
.login-investigation-list>div{grid-template-columns:1fr 1fr 1.2fr auto;align-items:center}.login-investigation-list>div>div{display:grid;gap:4px}.login-investigation-list small{color:var(--shd-text-dim);font-size:6px;letter-spacing:.4px}.deletion-purge-option{display:grid!important;grid-template-columns:20px 1fr;align-items:start;gap:8px;margin:14px 0!important;padding:10px;border:1px solid #70423b;background:#211414}.deletion-purge-option input{width:auto;margin:2px 0 0;accent-color:var(--shd-danger)}.deletion-purge-option span{display:grid;gap:4px}.deletion-purge-option b{color:var(--shd-danger);font-size:8px}.deletion-purge-option small{margin:0;color:#b18c85;font-size:7px}
|
||||
.site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.footer-brand .brand-mark{width:30px;height:30px;font-size:7px}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px Georgia,serif}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.fan-disclaimer{margin:0;color:#555d5f;font-size:6px;line-height:1.6;letter-spacing:.55px}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 Georgia,serif}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:9px;font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 Georgia,serif}.legal-document a{color:var(--shd-orange)}@media(max-width:800px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}}
|
||||
@media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}}
|
||||
.dashboard-row>.role-summary{display:flex;flex-wrap:wrap;align-items:center;gap:3px}.role-summary b{flex:0 0 auto;width:max-content;padding:3px 5px;border:1px solid #6e5226;color:var(--shd-orange);font-size:6px;letter-spacing:.6px}
|
||||
.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:55px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;color:#d7d9d5;font-size:9px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-dashboard-table{margin-top:10px}
|
||||
|
||||
@@ -42,3 +42,9 @@
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<footer class="site-footer">
|
||||
<div><a class="brand footer-brand" href="/"><span class="brand-mark"><i></i><b>SHD</b></span><span>SHD<span class="accent">.build</span></span></a><p>Independent loadout intelligence for the agent community.</p></div>
|
||||
<div class="footer-links"><a href="/terms">TERMS & PRIVACY</a><a href="/terms#retention">DATA RETENTION</a></div>
|
||||
<p class="fan-disclaimer">UNOFFICIAL FAN SITE · NOT AFFILIATED WITH OR ENDORSED BY UBISOFT. TOM CLANCY’S THE DIVISION, THE DIVISION RESURGENCE, UBISOFT, AND RELATED INTELLECTUAL PROPERTY ARE OWNED BY UBISOFT AND/OR ITS LICENSORS.</p>
|
||||
</footer>
|
||||
|
||||
@@ -6,7 +6,8 @@ export async function softDeleteAccount(
|
||||
database: Database,
|
||||
userId: string,
|
||||
deletedBy: string,
|
||||
reason: string | null
|
||||
reason: string | null,
|
||||
purgeSecurityData = false
|
||||
): Promise<boolean> {
|
||||
const user = await (await database.prepare(`
|
||||
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
|
||||
@@ -20,7 +21,7 @@ export async function softDeleteAccount(
|
||||
|
||||
await (await database.prepare(
|
||||
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
|
||||
)).run(userId, String(user.login_username ?? user.username), now, deletedBy, reason);
|
||||
)).run(userId, purgeSecurityData ? deletedUsername : String(user.login_username ?? user.username), now, deletedBy, reason);
|
||||
await (await database.prepare(
|
||||
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
|
||||
)).run(deletedUsername, userId);
|
||||
@@ -38,6 +39,9 @@ export async function softDeleteAccount(
|
||||
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 deleteUploadedAvatar(database, userId);
|
||||
if (purgeSecurityData) {
|
||||
await (await database.prepare('DELETE FROM login_events WHERE user_id=?')).run(userId);
|
||||
}
|
||||
await (await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'
|
||||
)).run(
|
||||
@@ -46,8 +50,8 @@ export async function softDeleteAccount(
|
||||
'user',
|
||||
userId,
|
||||
'account.soft_deleted',
|
||||
JSON.stringify({ username: user.username, displayName: user.display_name }),
|
||||
JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason }),
|
||||
purgeSecurityData ? JSON.stringify({ purgedAtOwnerRequest: true }) : JSON.stringify({ username: user.username, displayName: user.display_name }),
|
||||
JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason, securityDataPurged: purgeSecurityData }),
|
||||
now
|
||||
);
|
||||
return true;
|
||||
|
||||
@@ -17,13 +17,23 @@ export type AuthUser = {
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
type LoginRequestContext = {
|
||||
request?: Request;
|
||||
getClientAddress?: () => string;
|
||||
failureReason?: string | null;
|
||||
credentialId?: string | null;
|
||||
sessionId?: string | null;
|
||||
riskFlags?: string[] | null;
|
||||
};
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function createSession(cookies: Cookies, userId: string): Promise<void> {
|
||||
export async function createSession(cookies: Cookies, userId: string): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const sessionId = randomUUID();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
||||
const insert = await database.prepare(
|
||||
@@ -31,7 +41,7 @@ export async function createSession(cookies: Cookies, userId: string): Promise<v
|
||||
);
|
||||
|
||||
await insert.run(
|
||||
randomUUID(),
|
||||
sessionId,
|
||||
userId,
|
||||
hashToken(token),
|
||||
expiresAt.toISOString(),
|
||||
@@ -45,12 +55,53 @@ export async function createSession(cookies: Cookies, userId: string): Promise<v
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean): Promise<void> {
|
||||
function describeClient(userAgent: string) {
|
||||
const clientName = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\//.test(userAgent) ? 'Firefox' :
|
||||
/Chrome\//.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : 'Other';
|
||||
const operatingSystem = /Windows/.test(userAgent) ? 'Windows' : /Android/.test(userAgent) ? 'Android' :
|
||||
/iPhone|iPad/.test(userAgent) ? 'iOS/iPadOS' : /Mac OS X/.test(userAgent) ? 'macOS' :
|
||||
/Linux/.test(userAgent) ? 'Linux' : 'Other';
|
||||
const deviceType = /Mobile|Android|iPhone/.test(userAgent) ? 'Mobile' : /iPad|Tablet/.test(userAgent) ? 'Tablet' : 'Desktop';
|
||||
return { clientName, operatingSystem, deviceType };
|
||||
}
|
||||
|
||||
export async function purgeExpiredLoginMetadata(): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare('INSERT INTO login_events(id,user_id,provider,succeeded,created_at) VALUES(?,?,?,?,?)'))
|
||||
.run(randomUUID(), userId, provider, succeeded ? 1 : 0, new Date().toISOString());
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL,
|
||||
operating_system=NULL,device_type=NULL,location_country=NULL,location_region=NULL,location_city=NULL,
|
||||
credential_id=NULL,session_id=NULL,sensitive_purged_at=?
|
||||
WHERE created_at<? AND sensitive_purged_at IS NULL`)).run(now, cutoff);
|
||||
}
|
||||
|
||||
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean, context: LoginRequestContext = {}): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
const userAgent = context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
||||
const headers = context.request?.headers;
|
||||
const locationCountry = headers?.get('cf-ipcountry') ?? headers?.get('x-vercel-ip-country') ?? headers?.get('x-geo-country');
|
||||
const locationRegion = headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region');
|
||||
const locationCity = headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city');
|
||||
const client = describeClient(userAgent ?? '');
|
||||
const riskFlags = context.riskFlags ?? (!succeeded
|
||||
? [userId ? 'failed_authentication' : 'unknown_account_attempt']
|
||||
: []);
|
||||
let ipAddress: string | null = null;
|
||||
try { ipAddress = context.getClientAddress?.() ?? null; } catch { /* unavailable in some development adapters */ }
|
||||
await (await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
|
||||
client_name,operating_system,device_type,location_country,location_region,location_city,
|
||||
failure_reason,risk_flags,credential_id,session_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(), userId, provider, succeeded ? 1 : 0, ipAddress, userAgent,
|
||||
client.clientName, client.operatingSystem, client.deviceType,
|
||||
locationCountry?.slice(0, 100) ?? null, locationRegion?.slice(0, 100) ?? null, locationCity?.slice(0, 100) ?? null,
|
||||
context.failureReason ?? null, riskFlags.length ? riskFlags.join(',') : null,
|
||||
context.credentialId ?? null, context.sessionId ?? null, new Date().toISOString()
|
||||
);
|
||||
await purgeExpiredLoginMetadata();
|
||||
}
|
||||
|
||||
export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null> {
|
||||
@@ -107,6 +158,11 @@ export async function destroySession(cookies: Cookies): Promise<void> {
|
||||
|
||||
if (token) {
|
||||
const database = await getDatabase();
|
||||
const session = await (await database.prepare('SELECT id FROM sessions WHERE token_hash=?')).get(hashToken(token));
|
||||
if (session?.id) {
|
||||
await (await database.prepare('UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?'))
|
||||
.run(new Date().toISOString(), 'user_logout', session.id);
|
||||
}
|
||||
const statement = await database.prepare('DELETE FROM sessions WHERE token_hash = ?');
|
||||
await statement.run(hashToken(token));
|
||||
}
|
||||
|
||||
@@ -222,13 +222,14 @@ export const actions: Actions = {
|
||||
const form = await request.formData();
|
||||
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
const purgeSecurityData = form.get('purgeSecurityData') === 'on';
|
||||
if (confirmation !== 'DELETE') return fail(400, { deleteError: 'Enter DELETE exactly to confirm deletion.' });
|
||||
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, password))) {
|
||||
return fail(400, { deleteError: 'Your password is incorrect.' });
|
||||
}
|
||||
if (!(await softDeleteAccount(database, user.id, user.id, 'Deleted by account owner'))) {
|
||||
if (!(await softDeleteAccount(database, user.id, user.id, 'Deleted by account owner', purgeSecurityData))) {
|
||||
return fail(409, { deleteError: 'This account has already been deleted.' });
|
||||
}
|
||||
cookies.delete('shd_session', { path: '/' });
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
<div class="account-panel" id="account-panel" role="tabpanel" aria-labelledby="account-tab">
|
||||
<p class="eyebrow danger-eyebrow">ACCOUNT LIFECYCLE</p><h2>DELETE ACCOUNT</h2>
|
||||
<p class="account-retention">Your builds, guides, and comments remain attributed to a deleted agent. Your login username, display name, and Discord identity become available again.</p>
|
||||
<details class="danger-zone" open={Boolean(form?.deleteError)}><summary>OPEN DELETION CONTROLS</summary><PendingForm action="?/deleteAccount"><label class="field">TYPE DELETE 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>
|
||||
<details class="danger-zone" open={Boolean(form?.deleteError)}><summary>OPEN DELETION CONTROLS</summary><PendingForm action="?/deleteAccount"><label class="field">TYPE DELETE TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label><label class="deletion-purge-option"><input name="purgeSecurityData" type="checkbox"/><span><b>PURGE SECURITY HISTORY NOW</b><small>Immediately delete retained login events and their IP, device, session, and failure metadata instead of waiting for normal retention cleanup.</small></span></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -9,18 +9,23 @@ import { avatarUrl } from '$lib/avatar';
|
||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const database = await getDatabase();
|
||||
await purgeExpiredLoginMetadata();
|
||||
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
|
||||
const loginStatus = ['success', 'failed'].includes(url.searchParams.get('loginStatus') ?? '') ? String(url.searchParams.get('loginStatus')) : '';
|
||||
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
|
||||
const buildVisibility = ['draft', 'private', 'public'].includes(url.searchParams.get('buildVisibility') ?? '') ? String(url.searchParams.get('buildVisibility')) : '';
|
||||
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
|
||||
const pattern = (value: string) => `%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const loginWhere = `user_id=? AND provider LIKE ? ESCAPE '\\'${loginStatus ? ' AND succeeded=?' : ''}`;
|
||||
const loginArgs: unknown[] = [params.id, pattern(loginQuery), ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
|
||||
const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(client_name,'') LIKE ? ESCAPE '\\' OR COALESCE(operating_system,'') LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(failure_reason,'') LIKE ? ESCAPE '\\')${loginStatus ? ' AND succeeded=?' : ''}`;
|
||||
const loginPattern = pattern(loginQuery);
|
||||
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
|
||||
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
|
||||
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
|
||||
const commentWhere = `comments.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
|
||||
@@ -51,7 +56,14 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id),
|
||||
(await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id),
|
||||
(await database.prepare(`SELECT provider,succeeded,created_at FROM login_events WHERE ${loginWhere} ORDER BY created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
|
||||
(await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at,
|
||||
login_events.ip_address,login_events.user_agent,login_events.client_name,login_events.operating_system,
|
||||
login_events.device_type,login_events.location_country,login_events.location_region,login_events.location_city,
|
||||
login_events.failure_reason,login_events.risk_flags,login_events.credential_id,login_events.session_id,
|
||||
login_events.session_ended_at,login_events.session_end_reason,login_events.sensitive_purged_at,
|
||||
sessions.expires_at AS session_expires_at
|
||||
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
|
||||
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
|
||||
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
|
||||
(await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug
|
||||
FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
|
||||
<div class="user-history-registries">
|
||||
<section class="history-registry"><div class="history-head"><span>LOGIN HISTORY</span><b>{data.historyPagination.login.total} EVENTS</b></div><form class="history-filter"><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="loginQ" value={data.historyFilters.loginQuery} placeholder="PROVIDER"/><select name="loginStatus"><option value="">ALL RESULTS</option><option value="success" selected={data.historyFilters.loginStatus === 'success'}>SUCCESSFUL</option><option value="failed" selected={data.historyFilters.loginStatus === 'failed'}>FAILED</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.login} namespace="login"/><div class="history-list">{#each data.logins as login}<div><strong>{login.provider.toUpperCase()}</strong><span>{date(login.created_at)}</span><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||
<section class="history-registry"><div class="history-head"><span>LOGIN HISTORY · SENSITIVE METADATA RETAINED 30 DAYS</span><b>{data.historyPagination.login.total} EVENTS</b></div><form class="history-filter"><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="loginQ" value={data.historyFilters.loginQuery} placeholder="PROVIDER, IP, CLIENT, OS, OR FAILURE"/><select name="loginStatus"><option value="">ALL RESULTS</option><option value="success" selected={data.historyFilters.loginStatus === 'success'}>SUCCESSFUL</option><option value="failed" selected={data.historyFilters.loginStatus === 'failed'}>FAILED</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.login} namespace="login"/><div class="history-list login-investigation-list">{#each data.logins as login}<div><div><strong>{login.provider.toUpperCase()}</strong><small>{login.client_name ?? 'METADATA PURGED'} · {login.operating_system ?? 'UNKNOWN OS'} · {login.device_type ?? 'UNKNOWN DEVICE'}</small></div><div><span>{login.ip_address ?? 'IP PURGED'}{login.location_country ? ` · ${[login.location_city,login.location_region,login.location_country].filter(Boolean).join(', ')}` : ''}</span><small>{login.failure_reason ? `REASON: ${login.failure_reason}` : login.credential_id ? `CREDENTIAL: ${login.credential_id}` : 'NO FAILURE'}{login.risk_flags ? ` · FLAGS: ${login.risk_flags}` : ''}</small></div><div><span>{date(login.created_at)}</span><small>{login.session_ended_at ? `SESSION ENDED ${date(login.session_ended_at)} · ${login.session_end_reason}` : login.session_id ? (login.session_expires_at ? `SESSION EXPIRES ${date(login.session_expires_at)}` : 'SESSION REVOKED') : 'NO SESSION CREATED'}</small></div><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||
<section class="history-registry"><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.historyPagination.build.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="buildQ" value={data.historyFilters.buildQuery} placeholder="TITLE OR SLUG"/><select name="buildVisibility"><option value="">ALL VISIBILITY</option><option value="draft" selected={data.historyFilters.buildVisibility === 'draft'}>DRAFT</option><option value="private" selected={data.historyFilters.buildVisibility === 'private'}>PRIVATE</option><option value="public" selected={data.historyFilters.buildVisibility === 'public'}>PUBLIC</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.build} namespace="build"/><div class="history-list">{#each data.builds as build}<div><strong>{build.title}</strong><span>{date(build.updated_at)}</span><b>{build.visibility.toUpperCase()}</b></div>{:else}<p>NO BUILDS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||
<section class="history-registry"><div class="history-head"><span>COMMENT HISTORY</span><b>{data.historyPagination.comment.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input name="commentQ" value={data.historyFilters.commentQuery} placeholder="COMMENT OR BUILD TITLE"/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.comment} namespace="comment"/><div class="history-list comment-history">{#each data.comments as comment}<div><strong>{comment.title}</strong><span>{comment.body}</span><b>{date(comment.created_at)}</b></div>{:else}<p>NO COMMENTS MATCH THIS SEARCH</p>{/each}</div></section>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ async function uniqueUsername(): Promise<string> {
|
||||
return `agent_${randomUUID().replaceAll('-', '').slice(0, 20)}`;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
|
||||
export const GET: RequestHandler = async ({ cookies, url, fetch, request, getClientAddress }) => {
|
||||
const expectedState = cookies.get(STATE_COOKIE);
|
||||
cookies.delete(STATE_COOKIE, { path: '/auth/discord' });
|
||||
|
||||
@@ -128,11 +128,11 @@ export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
|
||||
const status = await (await database.prepare('SELECT blocked_at,banned_until FROM user_status WHERE user_id=?')).get(String(user.id));
|
||||
const banned = typeof status?.banned_until === 'string' && new Date(status.banned_until) > new Date();
|
||||
if (status?.blocked_at || banned) {
|
||||
await recordLogin(String(user.id), 'discord', false);
|
||||
await recordLogin(String(user.id), 'discord', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: discordUser.id });
|
||||
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);
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'discord', true, { request, getClientAddress, credentialId: discordUser.id, sessionId });
|
||||
redirect(303, created ? '/account?choose_display_name=1' : '/account');
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ url, cookies, request, getClientAddress }) => {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const database = await getDatabase();
|
||||
const link = token ? await consumeMagicLinkToken(database, token) : null;
|
||||
@@ -17,7 +17,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const unavailable = !user || user.deleted_at || user.blocked_at ||
|
||||
(typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date());
|
||||
if (unavailable) {
|
||||
await recordLogin(link.userId, 'magic_link', false);
|
||||
await recordLogin(link.userId, 'magic_link', false, { request, getClientAddress, failureReason: 'account_restricted' });
|
||||
redirect(303, '/sign-in?magic_error=unavailable');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
.run(link.userId, link.email, now, now);
|
||||
}
|
||||
await ensureBootstrapAdmin(database, link.userId);
|
||||
await createSession(cookies, link.userId);
|
||||
await recordLogin(link.userId, 'magic_link', true);
|
||||
const sessionId = await createSession(cookies, link.userId);
|
||||
await recordLogin(link.userId, 'magic_link', true, { request, getClientAddress, sessionId });
|
||||
redirect(303, link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account');
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ function bytes(value: unknown): Uint8Array<ArrayBuffer> {
|
||||
throw new Error('Invalid passkey public key.');
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
export const POST: RequestHandler = async ({ cookies, request, getClientAddress }) => {
|
||||
const body = await request.json() as { challengeId?: string; response?: AuthenticationResponseJSON };
|
||||
if (!body.challengeId || !body.response) error(400, 'Incomplete passkey authentication.');
|
||||
const database = await getDatabase();
|
||||
@@ -31,7 +31,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
const unavailable = passkey.deleted_at || passkey.blocked_at ||
|
||||
(typeof passkey.banned_until === 'string' && new Date(passkey.banned_until) > new Date());
|
||||
if (unavailable) {
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false);
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: String(passkey.id) });
|
||||
error(403, 'This account is not permitted to sign in.');
|
||||
}
|
||||
const transports = passkey.transports ? JSON.parse(String(passkey.transports)) as AuthenticatorTransportFuture[] : undefined;
|
||||
@@ -44,14 +44,14 @@ export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
requireUserVerification: true
|
||||
});
|
||||
if (!verification.verified) {
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false);
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'verification_failed', credentialId: String(passkey.id) });
|
||||
error(400, 'Passkey authentication could not be verified.');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare('UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?'))
|
||||
.run(verification.authenticationInfo.newCounter, now, passkey.id);
|
||||
await ensureBootstrapAdmin(database, String(passkey.user_id));
|
||||
await createSession(cookies, String(passkey.user_id));
|
||||
await recordLogin(String(passkey.user_id), 'passkey', true);
|
||||
const sessionId = await createSession(cookies, String(passkey.user_id));
|
||||
await recordLogin(String(passkey.user_id), 'passkey', true, { request, getClientAddress, credentialId: String(passkey.id), sessionId });
|
||||
return json({ verified: true, redirectTo: '/account' });
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ export const actions: Actions = {
|
||||
}
|
||||
return { magicLinkSent: true };
|
||||
},
|
||||
register: async ({ request, cookies }) => {
|
||||
register: async ({ request, cookies, getClientAddress }) => {
|
||||
const form = await request.formData();
|
||||
const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim();
|
||||
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
|
||||
@@ -83,11 +83,11 @@ export const actions: Actions = {
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, displayName, now);
|
||||
await ensureBootstrapAdmin(database, id);
|
||||
await createSession(cookies, id);
|
||||
await recordLogin(id, 'password', true);
|
||||
const sessionId = await createSession(cookies, id);
|
||||
await recordLogin(id, 'password', true, { request, getClientAddress, sessionId });
|
||||
redirect(303, '/account');
|
||||
},
|
||||
login: async ({ request, cookies }) => {
|
||||
login: async ({ request, cookies, getClientAddress }) => {
|
||||
const form = await request.formData();
|
||||
const username = String(form.get('username') ?? '').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
@@ -97,17 +97,17 @@ export const actions: Actions = {
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE login_username = ? COLLATE NOCASE`)).get(username);
|
||||
const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password);
|
||||
if (!valid) {
|
||||
if (user?.id) await recordLogin(String(user.id), 'password', false);
|
||||
await recordLogin(user?.id ? String(user.id) : null, 'password', false, { request, getClientAddress, failureReason: 'invalid_credentials' });
|
||||
return fail(400, { loginError: 'Invalid username or password.', username });
|
||||
}
|
||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||
if (user.blocked_at || banned) {
|
||||
await recordLogin(String(user.id), 'password', false);
|
||||
await recordLogin(String(user.id), 'password', false, { request, getClientAddress, failureReason: 'account_restricted' });
|
||||
return fail(403, { loginError: 'This account is not permitted to sign in.', username });
|
||||
}
|
||||
await ensureBootstrapAdmin(database, String(user.id));
|
||||
await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'password', true);
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'password', true, { request, getClientAddress, sessionId });
|
||||
redirect(303, '/account');
|
||||
},
|
||||
logout: async ({ cookies }) => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<svelte:head>
|
||||
<title>Terms & Privacy — SHD.build</title>
|
||||
<meta name="description" content="Terms of service, privacy practices, and fan-site disclaimer for SHD.build."/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="legal-page">
|
||||
<header class="legal-hero">
|
||||
<p class="eyebrow"><span></span> SHD NETWORK POLICY</p>
|
||||
<h1>TERMS & <em>PRIVACY.</em></h1>
|
||||
<p>Effective July 25, 2026. These terms describe the rules for using SHD.build and the information processed to operate and secure the service.</p>
|
||||
</header>
|
||||
|
||||
<div class="legal-layout">
|
||||
<nav class="legal-index" aria-label="Terms sections">
|
||||
<a href="#service">01 · SERVICE</a><a href="#accounts">02 · ACCOUNTS</a>
|
||||
<a href="#content">03 · USER CONTENT</a><a href="#conduct">04 · CONDUCT</a>
|
||||
<a href="#privacy">05 · PRIVACY</a><a href="#retention">06 · RETENTION</a>
|
||||
<a href="#third-parties">07 · THIRD PARTIES</a><a href="#disclaimers">08 · DISCLAIMERS</a>
|
||||
</nav>
|
||||
<article class="legal-document">
|
||||
<section id="service"><span>01</span><div><h2>THE SERVICE</h2><p>SHD.build is an independent community loadout, build-guide, and discussion service. By accessing or using it, you agree to these terms. If you do not agree, do not use the service. Features may change, be suspended, or be discontinued as development continues.</p></div></section>
|
||||
<section id="accounts"><span>02</span><div><h2>ACCOUNTS & SECURITY</h2><p>You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.</p><p>Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.</p></div></section>
|
||||
<section id="content"><span>03</span><div><h2>USER CONTENT</h2><p>You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.</p><p>Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.</p></div></section>
|
||||
<section id="conduct"><span>04</span><div><h2>COMMUNITY CONDUCT</h2><p>Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Moderators may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.</p></div></section>
|
||||
<section id="privacy"><span>05</span><div><h2>INFORMATION WE PROCESS</h2><p>Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, and comments. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.</p><p>For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.</p><p>Session cookies are required to keep you signed in. OAuth and email providers process information under their own policies.</p></div></section>
|
||||
<section id="retention"><span>06</span><div><h2>RETENTION & DELETION</h2><p>IP addresses, user-agent strings, normalized device information, and credential references in login history are retained for no more than 30 days, then automatically removed from the event. Non-sensitive facts such as event time, provider, outcome, failure category, and whether a session was created may remain for security and support history.</p><p>Deleting your account removes active sessions, credentials, verified email, provider profiles, passkeys, permissions, and uploaded avatar. You may also select immediate purging of retained login-event history during self-deletion. Public contributions may remain attributed to “Deleted Agent.” Backups and records required for fraud prevention, dispute resolution, or law may persist for a limited period where applicable.</p></div></section>
|
||||
<section id="third-parties"><span>07</span><div><h2>THIRD-PARTY SERVICES</h2><p>SHD.build may connect to Discord, email delivery services, and future identity providers. Their services are governed by their own terms. Links to other sites do not imply endorsement. You authorize the minimum provider data requested when choosing a linked sign-in method.</p></div></section>
|
||||
<section id="disclaimers"><span>08</span><div><h2>FAN-SITE & LEGAL DISCLAIMER</h2><p>SHD.build is an unofficial fan-created service and is not affiliated with, endorsed by, sponsored by, or approved by Ubisoft Entertainment or its affiliates.</p><p>Tom Clancy’s The Division, Tom Clancy’s The Division Resurgence, Ubisoft, and related names, trademarks, logos, characters, artwork, game data, and other intellectual property are owned by Ubisoft and/or its licensors. Their use is for identification, commentary, and community reference. No ownership is claimed.</p><p>The service and community information are provided “as is” and “as available,” without warranties of accuracy, availability, fitness for a particular purpose, or non-infringement to the extent permitted by law. Build data may become outdated after game updates. SHD.build is not responsible for third-party services or decisions made using community content.</p><p>These terms may be updated as the service changes. Material revisions will be identified by a new effective date. Nothing here limits rights that cannot legally be waived.</p><p>For authoritative information about Ubisoft intellectual property, consult Ubisoft’s official <a href="https://www.ubisoft.com/legal/documents/termsofuse/en-US" rel="external">Terms of Use</a> and the official <a href="https://www.ubisoft.com/game/the-division/resurgence" rel="external">The Division Resurgence site</a>.</p></div></section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
Reference in New Issue
Block a user