Add soft account deletion safeguards
This commit is contained in:
@@ -31,6 +31,13 @@ CREATE TABLE IF NOT EXISTS login_events (
|
|||||||
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS deleted_accounts (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id),
|
||||||
|
original_username TEXT NOT NULL,
|
||||||
|
deleted_at TEXT NOT NULL,
|
||||||
|
deleted_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
reason TEXT
|
||||||
|
);
|
||||||
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
|
||||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management');
|
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management');
|
||||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users');
|
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users');
|
||||||
@@ -43,6 +50,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-m
|
|||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-view','users.view','View users, activity, and content history');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-view','users.view','View users, activity, and content history');
|
||||||
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-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-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 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-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-data-editor','permission-data-manage');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
|
|||||||
unrestricted administrative role; `Moderator` is reserved for community
|
unrestricted administrative role; `Moderator` is reserved for community
|
||||||
content moderation and does not imply ISAC access. Operational ISAC access is
|
content moderation and does not imply ISAC access. Operational ISAC access is
|
||||||
delegated separately through scoped roles and permissions.
|
delegated separately through scoped roles and permissions.
|
||||||
|
- Account deletion uses a collapsed danger zone, explicit identity confirmation,
|
||||||
|
and plain-language retention guidance. Soft-deleted accounts retain authored
|
||||||
|
content under a deleted-agent identity while releasing credentials, usernames,
|
||||||
|
external identity links, sessions, roles, and permissions.
|
||||||
- Threshold-based set bonuses use compact `effect-band` groups. Each threshold is
|
- Threshold-based set bonuses use compact `effect-band` groups. Each threshold is
|
||||||
displayed as a fixed piece-count marker followed by its mapped stat and value,
|
displayed as a fixed piece-count marker followed by its mapped stat and value,
|
||||||
keeping progression readable in editors and table summaries.
|
keeping progression readable in editors and table summaries.
|
||||||
|
|||||||
@@ -56,6 +56,8 @@
|
|||||||
.site-header .brand-mark{position:relative}.site-header .brand-mark i{position:absolute;inset:3px;border:1px solid #f6a82155;border-radius:50%}.site-header .brand-mark b{position:relative;font-size:8px}.brand-name{display:grid;font-size:14px;line-height:1}.brand-name>span{display:inline;color:#f6a821}.brand-name small{font-size:5px;color:#555d5f;letter-spacing:1.3px;margin-top:4px}.site-header .account small i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success);margin-right:3px}
|
.site-header .brand-mark{position:relative}.site-header .brand-mark i{position:absolute;inset:3px;border:1px solid #f6a82155;border-radius:50%}.site-header .brand-mark b{position:relative;font-size:8px}.brand-name{display:grid;font-size:14px;line-height:1}.brand-name>span{display:inline;color:#f6a821}.brand-name small{font-size:5px;color:#555d5f;letter-spacing:1.3px;margin-top:4px}.site-header .account small i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success);margin-right:3px}
|
||||||
.catalogue-page,.guides-page,.profile-page{padding-top:34px}.catalogue-hero{min-height:230px;display:flex;justify-content:space-between;align-items:center;padding:25px 32px;margin-bottom:12px;border:1px solid var(--shd-border);background:radial-gradient(circle at 80% 50%,#332714 0,transparent 30%),linear-gradient(120deg,#141718,#0d1011);clip-path:polygon(0 0,97% 0,100% 18%,100% 100%,3% 100%,0 82%)}.catalogue-hero h1{font-size:clamp(48px,6vw,82px);line-height:.82;letter-spacing:-3px;margin:14px 0}.catalogue-hero h1 em{font-style:normal;color:var(--shd-orange)}.catalogue-hero>div>p:last-child{max-width:580px;color:var(--shd-text-muted);font:15px/1.55 Georgia,serif}.catalogue-count{min-width:210px;display:grid;text-align:right}.catalogue-count strong{font-size:38px}.catalogue-count span{font-size:7px;color:#6d7476;letter-spacing:1.5px;margin:3px 0 18px}.catalogue-count .primary{justify-self:end}.filter-bar{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:7px;align-items:end;margin:12px 0 18px;padding:11px;background:#101314;border:1px solid #2d3233}.filter-bar label{font-size:7px;color:#72797b;letter-spacing:1.2px}.filter-bar input,.filter-bar select{height:38px;margin-top:6px;padding:0 10px;font-size:9px}.filter-bar button{height:38px;padding:0 14px}.catalogue-grid{max-width:none}.catalogue-grid article{position:relative}.build-open{width:100%;margin-top:15px;padding:10px;border:1px solid #3a4041;background:#101314;color:#f6a821;font-size:8px;font-weight:bold;letter-spacing:1px}.guide-layout{display:grid;grid-template-columns:1fr 330px;gap:12px}.guide-feature{min-height:360px;padding:35px;position:relative;border:1px solid #303536;border-top:2px solid #f6a821;background:linear-gradient(135deg,#151819,#0d1011)}.guide-code{position:absolute;right:25px;top:22px;color:#4f5658;font-size:8px;letter-spacing:1.5px}.guide-feature h2{font-size:42px;line-height:.95;margin:20px 0}.guide-feature>p:not(.eyebrow){max-width:650px;color:#858b8d;font:15px/1.6 Georgia,serif}.guide-meta{display:flex;gap:8px;position:absolute;left:35px;bottom:30px}.guide-meta span{display:grid;padding:8px 11px;border:1px solid #303536;color:#60686a;font-size:7px;letter-spacing:1px}.guide-meta b{color:#d7d9d5;font-size:9px;margin-top:4px}.guide-index{border:1px solid #303536;background:#111415;padding:20px}.guide-index>div{display:grid;grid-template-columns:30px 1fr auto;align-items:center;min-height:54px;border-top:1px solid #292e2f}.guide-index small{color:#535b5d;font-size:8px}.guide-index span{font-size:9px;letter-spacing:.8px}.guide-index b{color:#f6a821}
|
.catalogue-page,.guides-page,.profile-page{padding-top:34px}.catalogue-hero{min-height:230px;display:flex;justify-content:space-between;align-items:center;padding:25px 32px;margin-bottom:12px;border:1px solid var(--shd-border);background:radial-gradient(circle at 80% 50%,#332714 0,transparent 30%),linear-gradient(120deg,#141718,#0d1011);clip-path:polygon(0 0,97% 0,100% 18%,100% 100%,3% 100%,0 82%)}.catalogue-hero h1{font-size:clamp(48px,6vw,82px);line-height:.82;letter-spacing:-3px;margin:14px 0}.catalogue-hero h1 em{font-style:normal;color:var(--shd-orange)}.catalogue-hero>div>p:last-child{max-width:580px;color:var(--shd-text-muted);font:15px/1.55 Georgia,serif}.catalogue-count{min-width:210px;display:grid;text-align:right}.catalogue-count strong{font-size:38px}.catalogue-count span{font-size:7px;color:#6d7476;letter-spacing:1.5px;margin:3px 0 18px}.catalogue-count .primary{justify-self:end}.filter-bar{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:7px;align-items:end;margin:12px 0 18px;padding:11px;background:#101314;border:1px solid #2d3233}.filter-bar label{font-size:7px;color:#72797b;letter-spacing:1.2px}.filter-bar input,.filter-bar select{height:38px;margin-top:6px;padding:0 10px;font-size:9px}.filter-bar button{height:38px;padding:0 14px}.catalogue-grid{max-width:none}.catalogue-grid article{position:relative}.build-open{width:100%;margin-top:15px;padding:10px;border:1px solid #3a4041;background:#101314;color:#f6a821;font-size:8px;font-weight:bold;letter-spacing:1px}.guide-layout{display:grid;grid-template-columns:1fr 330px;gap:12px}.guide-feature{min-height:360px;padding:35px;position:relative;border:1px solid #303536;border-top:2px solid #f6a821;background:linear-gradient(135deg,#151819,#0d1011)}.guide-code{position:absolute;right:25px;top:22px;color:#4f5658;font-size:8px;letter-spacing:1.5px}.guide-feature h2{font-size:42px;line-height:.95;margin:20px 0}.guide-feature>p:not(.eyebrow){max-width:650px;color:#858b8d;font:15px/1.6 Georgia,serif}.guide-meta{display:flex;gap:8px;position:absolute;left:35px;bottom:30px}.guide-meta span{display:grid;padding:8px 11px;border:1px solid #303536;color:#60686a;font-size:7px;letter-spacing:1px}.guide-meta b{color:#d7d9d5;font-size:9px;margin-top:4px}.guide-index{border:1px solid #303536;background:#111415;padding:20px}.guide-index>div{display:grid;grid-template-columns:30px 1fr auto;align-items:center;min-height:54px;border-top:1px solid #292e2f}.guide-index small{color:#535b5d;font-size:8px}.guide-index span{font-size:9px;letter-spacing:.8px}.guide-index b{color:#f6a821}
|
||||||
.profile-command{display:flex;justify-content:space-between;align-items:center;padding:0 0 24px;border-bottom:1px solid #303536}.profile-ident{display:flex;align-items:center;gap:18px}.profile-avatar{width:76px;height:76px;display:grid;place-items:center;border:1px solid #f6a821;border-radius:50%;color:#f6a821;font-size:29px;font-weight:900;box-shadow:inset 0 0 0 6px #0a0c0d,inset 0 0 0 7px #f6a82155}.profile-ident h1{font-size:39px;margin:4px 0}.role-badge{display:inline-block;padding:4px 8px;border:1px solid #6e5226;color:#f6a821;font-size:7px;letter-spacing:1.3px}.profile-metrics{display:flex;gap:7px}.profile-metrics div{min-width:77px;height:59px;display:grid;place-items:center;padding:7px;background:#111415;border:1px solid #303536}.profile-metrics strong{font-size:20px}.profile-metrics span{font-size:7px;color:#697174;letter-spacing:1px}.account-alert{display:flex;gap:16px;margin:12px 0;padding:12px 15px;background:#2a2112;border:1px solid #795c2a;border-left:3px solid #f6a821;font-size:10px}.account-alert b{color:#f6a821}.account-alert span{color:#b8ad99}.profile-dashboard{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;margin-top:12px}.profile-feed{display:grid;gap:12px}.profile-module,.account-console{background:#111415;border:1px solid #303536;padding:16px}.module-head{display:flex;justify-content:space-between;align-items:center}.module-head h2,.profile-module h2,.account-console h2{font-size:17px;margin:4px 0 12px}.module-head .secondary{padding:10px 12px}.profile-table{border:1px solid #292e2f}.profile-row{display:grid;grid-template-columns:1.5fr .7fr .7fr .3fr;align-items:center;gap:9px;min-height:47px;padding:7px 11px;border-top:1px solid #292e2f;font-size:9px}.profile-row.heading{min-height:30px;border:0;background:#0d1011;color:#636b6d;font-size:7px;letter-spacing:1px}.profile-row>div{display:grid}.profile-row small{font-size:7px;color:#596164}.profile-row>span{text-transform:uppercase;color:#858d8f}.profile-row button{border:0;background:transparent;color:#f6a821;font-size:8px}.profile-empty{text-align:center;padding:38px}.profile-empty>span{font-size:31px;color:#f6a821}.profile-empty b{display:block;font-size:10px;letter-spacing:1px;margin:10px}.profile-empty p{color:#71797b;font:13px Georgia,serif}.profile-empty a{color:#f6a821;font-size:8px;font-weight:bold}.profile-columns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.large-count{font-size:32px}.count-label{display:block;font-size:7px;color:#687073;letter-spacing:1.2px}.account-console{height:max-content}.console-head{display:flex;justify-content:space-between;padding-bottom:11px;margin-bottom:15px;border-bottom:1px solid #303536;color:#747c7e;font-size:7px;letter-spacing:1.3px}.console-head i{width:6px;height:6px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c}.console-action{width:100%;margin-top:9px}.connection-card{display:grid;grid-template-columns:35px 1fr auto;align-items:center;padding:10px;border:1px solid #303536}.discord-node{color:#8993ff;font-size:21px}.connection-card div{display:grid}.connection-card b{font-size:9px}.connection-card small{font-size:7px;color:#656d6f;margin-top:3px}.connection-card a{color:#f6a821;font-size:8px;font-weight:bold}
|
.profile-command{display:flex;justify-content:space-between;align-items:center;padding:0 0 24px;border-bottom:1px solid #303536}.profile-ident{display:flex;align-items:center;gap:18px}.profile-avatar{width:76px;height:76px;display:grid;place-items:center;border:1px solid #f6a821;border-radius:50%;color:#f6a821;font-size:29px;font-weight:900;box-shadow:inset 0 0 0 6px #0a0c0d,inset 0 0 0 7px #f6a82155}.profile-ident h1{font-size:39px;margin:4px 0}.role-badge{display:inline-block;padding:4px 8px;border:1px solid #6e5226;color:#f6a821;font-size:7px;letter-spacing:1.3px}.profile-metrics{display:flex;gap:7px}.profile-metrics div{min-width:77px;height:59px;display:grid;place-items:center;padding:7px;background:#111415;border:1px solid #303536}.profile-metrics strong{font-size:20px}.profile-metrics span{font-size:7px;color:#697174;letter-spacing:1px}.account-alert{display:flex;gap:16px;margin:12px 0;padding:12px 15px;background:#2a2112;border:1px solid #795c2a;border-left:3px solid #f6a821;font-size:10px}.account-alert b{color:#f6a821}.account-alert span{color:#b8ad99}.profile-dashboard{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;margin-top:12px}.profile-feed{display:grid;gap:12px}.profile-module,.account-console{background:#111415;border:1px solid #303536;padding:16px}.module-head{display:flex;justify-content:space-between;align-items:center}.module-head h2,.profile-module h2,.account-console h2{font-size:17px;margin:4px 0 12px}.module-head .secondary{padding:10px 12px}.profile-table{border:1px solid #292e2f}.profile-row{display:grid;grid-template-columns:1.5fr .7fr .7fr .3fr;align-items:center;gap:9px;min-height:47px;padding:7px 11px;border-top:1px solid #292e2f;font-size:9px}.profile-row.heading{min-height:30px;border:0;background:#0d1011;color:#636b6d;font-size:7px;letter-spacing:1px}.profile-row>div{display:grid}.profile-row small{font-size:7px;color:#596164}.profile-row>span{text-transform:uppercase;color:#858d8f}.profile-row button{border:0;background:transparent;color:#f6a821;font-size:8px}.profile-empty{text-align:center;padding:38px}.profile-empty>span{font-size:31px;color:#f6a821}.profile-empty b{display:block;font-size:10px;letter-spacing:1px;margin:10px}.profile-empty p{color:#71797b;font:13px Georgia,serif}.profile-empty a{color:#f6a821;font-size:8px;font-weight:bold}.profile-columns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.large-count{font-size:32px}.count-label{display:block;font-size:7px;color:#687073;letter-spacing:1.2px}.account-console{height:max-content}.console-head{display:flex;justify-content:space-between;padding-bottom:11px;margin-bottom:15px;border-bottom:1px solid #303536;color:#747c7e;font-size:7px;letter-spacing:1.3px}.console-head i{width:6px;height:6px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c}.console-action{width:100%;margin-top:9px}.connection-card{display:grid;grid-template-columns:35px 1fr auto;align-items:center;padding:10px;border:1px solid #303536}.discord-node{color:#8993ff;font-size:21px}.connection-card div{display:grid}.connection-card b{font-size:9px}.connection-card small{font-size:7px;color:#656d6f;margin-top:3px}.connection-card a{color:#f6a821;font-size:8px;font-weight:bold}
|
||||||
|
.account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1}
|
||||||
|
.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px}
|
||||||
.auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px}
|
.auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px}
|
||||||
.builder-page{padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:7px;letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821}
|
.builder-page{padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:7px;letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821}
|
||||||
@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}}
|
@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}}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export async function softDeleteAccount(
|
||||||
|
database: Database,
|
||||||
|
userId: string,
|
||||||
|
deletedBy: string,
|
||||||
|
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
|
||||||
|
WHERE users.id=?
|
||||||
|
`)).get(userId);
|
||||||
|
if (!user || user.deleted_at) return false;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
||||||
|
|
||||||
|
await (await database.prepare(
|
||||||
|
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
|
||||||
|
)).run(userId, String(user.username), now, deletedBy, reason);
|
||||||
|
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('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 user_roles WHERE user_id=?')).run(userId);
|
||||||
|
await (await database.prepare('DELETE FROM user_permissions 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(
|
||||||
|
randomUUID(),
|
||||||
|
deletedBy,
|
||||||
|
'user',
|
||||||
|
userId,
|
||||||
|
'account.soft_deleted',
|
||||||
|
JSON.stringify({ username: user.username }),
|
||||||
|
JSON.stringify({ username: deletedUsername, reason }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -56,10 +56,12 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const statement = await database.prepare(`
|
const statement = await database.prepare(`
|
||||||
SELECT users.id, users.username, users.role, users.discord_id,
|
SELECT users.id, users.username, users.role, users.discord_id,
|
||||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until
|
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||||
|
deleted_accounts.deleted_at
|
||||||
FROM sessions
|
FROM sessions
|
||||||
JOIN users ON users.id = sessions.user_id
|
JOIN users ON users.id = sessions.user_id
|
||||||
LEFT JOIN user_status ON user_status.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 > ?
|
WHERE sessions.token_hash = ? AND sessions.expires_at > ?
|
||||||
`);
|
`);
|
||||||
const user = await statement.get(hashToken(token), new Date().toISOString());
|
const user = await statement.get(hashToken(token), new Date().toISOString());
|
||||||
@@ -70,7 +72,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
|||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > now;
|
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > now;
|
||||||
if (user.blocked_at || banned) {
|
if (user.deleted_at || user.blocked_at || banned) {
|
||||||
await (await database.prepare('DELETE FROM sessions WHERE token_hash=?')).run(hashToken(token));
|
await (await database.prepare('DELETE FROM sessions WHERE token_hash=?')).run(hashToken(token));
|
||||||
cookies.delete(COOKIE_NAME, { path: '/' });
|
cookies.delete(COOKIE_NAME, { path: '/' });
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSession, getSessionUser } from '$lib/server/auth/session';
|
import { createSession, getSessionUser } from '$lib/server/auth/session';
|
||||||
|
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||||||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
@@ -14,7 +15,28 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(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 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, 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)
|
||||||
]);
|
]);
|
||||||
return { account: { builds, guides, favorites, ratings }, chooseUsername: url.searchParams.has('choose_username'), discordLinked: url.searchParams.has('discord_linked'), oauthError: url.searchParams.has('oauth_error') };
|
const oauthErrorCode = url.searchParams.get('oauth_error');
|
||||||
|
const oauthErrors: Record<string, { title: string; message: string }> = {
|
||||||
|
discord_in_use: {
|
||||||
|
title: 'DISCORD ACCOUNT ALREADY LINKED',
|
||||||
|
message: 'That Discord account belongs to another SHD.build agent. No accounts were changed. Sign out, then use Continue with Discord to access the existing account.'
|
||||||
|
},
|
||||||
|
link_session_changed: {
|
||||||
|
title: 'SESSION CHANGED',
|
||||||
|
message: 'Your SHD.build session changed while Discord was connecting. Sign in to the intended account and try linking again.'
|
||||||
|
},
|
||||||
|
discord_denied: {
|
||||||
|
title: 'DISCORD AUTHORIZATION CANCELLED',
|
||||||
|
message: 'Discord did not authorize the connection. Your SHD.build account was not changed.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const oauthError = oauthErrorCode
|
||||||
|
? oauthErrors[oauthErrorCode] ?? {
|
||||||
|
title: 'DISCORD LINK FAILED',
|
||||||
|
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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -43,5 +65,23 @@ export const actions: Actions = {
|
|||||||
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
|
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
|
||||||
await createSession(cookies, user.id);
|
await createSession(cookies, user.id);
|
||||||
return { passwordChanged: true };
|
return { passwordChanged: true };
|
||||||
|
},
|
||||||
|
deleteAccount: async ({ request, cookies }) => {
|
||||||
|
const user = await getSessionUser(cookies);
|
||||||
|
if (!user) redirect(303, '/sign-in');
|
||||||
|
const form = await request.formData();
|
||||||
|
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
if (confirmation !== user.username) return fail(400, { deleteError: `Enter ${user.username} 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'))) {
|
||||||
|
return fail(409, { deleteError: 'This account has already been deleted.' });
|
||||||
|
}
|
||||||
|
cookies.delete('shd_session', { path: '/' });
|
||||||
|
redirect(303, '/?account_deleted=1');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</section>
|
</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.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.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
|
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
|
||||||
{#if data.oauthError}<p class="error">Discord linking could not be completed.</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">
|
<div class="profile-dashboard">
|
||||||
<section class="profile-feed">
|
<section class="profile-feed">
|
||||||
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
|
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
|
||||||
@@ -27,6 +27,6 @@
|
|||||||
</div>
|
</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>
|
<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>
|
</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></aside>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const users = await (await database.prepare(`
|
const users = await (await database.prepare(`
|
||||||
SELECT users.id,users.username,users.role,users.discord_id,users.created_at,
|
SELECT users.id,users.username,users.role,users.discord_id,users.created_at,
|
||||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
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 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 builds WHERE owner_id=users.id) AS build_count,
|
||||||
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_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
|
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||||
|
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||||
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
@@ -25,7 +27,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const activity = user.last_active_at ?? user.last_login_at;
|
const activity = user.last_active_at ?? user.last_login_at;
|
||||||
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||||
return { ...user, inactive, accessState: user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' };
|
return { ...user, inactive, accessState: user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' };
|
||||||
}),
|
}),
|
||||||
query
|
query
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||||||
import { error, fail } from '@sveltejs/kit';
|
import { error, fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -9,8 +10,10 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
|
|||||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments] = await Promise.all([
|
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,users.role,users.discord_id,users.created_at,user_status.*,
|
||||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE users.id=?`)).get(params.id),
|
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 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 roles ORDER BY name')).all(),
|
||||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
(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 role_id FROM user_roles WHERE user_id=?')).all(params.id),
|
||||||
@@ -82,5 +85,22 @@ export const actions: Actions = {
|
|||||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||||
.run(randomUUID(), operator.id, 'user', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
|
.run(randomUUID(), operator.id, 'user', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
|
||||||
return { moderationSaved: true };
|
return { moderationSaved: true };
|
||||||
|
},
|
||||||
|
deleteAccount: async ({ cookies, request, params }) => {
|
||||||
|
const operator = await requirePermission(await getSessionUser(cookies), 'users.delete');
|
||||||
|
if (operator.id === params.id) return fail(400, { adminDeleteError: 'Delete your own account from the agent profile page.' });
|
||||||
|
const reason = String((await request.formData()).get('reason') ?? '').trim();
|
||||||
|
if (reason.length < 3) return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||||
|
const database = await getDatabase();
|
||||||
|
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||||
|
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
||||||
|
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||||
|
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
||||||
|
return fail(403, { adminDeleteError: 'Only an administrator can delete another administrator.' });
|
||||||
|
}
|
||||||
|
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
||||||
|
return fail(409, { adminDeleteError: 'This account has already been deleted.' });
|
||||||
|
}
|
||||||
|
return { accountDeleted: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
onMount(() => { roleIds = [...data.assignedRoleIds]; permissionIds = [...data.directPermissionIds]; });
|
onMount(() => { roleIds = [...data.assignedRoleIds]; permissionIds = [...data.directPermissionIds]; });
|
||||||
const can = (permission: string) => data.operatorPermissions.includes('*') || data.operatorPermissions.includes(permission);
|
const can = (permission: string) => data.operatorPermissions.includes('*') || data.operatorPermissions.includes(permission);
|
||||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||||
const accountState = () => data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
|
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>
|
</script>
|
||||||
<svelte:head><title>{data.profile.username} — ISAC user operations</title></svelte:head>
|
<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>
|
<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>
|
||||||
@@ -28,9 +28,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="dashboard-editor moderation-panel">
|
<section class="dashboard-editor moderation-panel">
|
||||||
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
|
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
|
||||||
{#if can('users.moderate')}
|
{#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>
|
<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}
|
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
|
||||||
|
{#if can('users.delete') && !data.profile.deleted_at}<hr/><details class="danger-zone"><summary>SOFT-DELETE ACCOUNT</summary><p>Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.</p><PendingForm action="?/deleteAccount"><label class="field">DELETION REASON<textarea name="reason" required></textarea></label>{#if form?.adminDeleteError}<p class="error">{form.adminDeleteError}</p>{/if}<button class="danger console-action" disabled={data.isSelf}>DELETE ACCOUNT</button></PendingForm></details>{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user