diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index b2ff812..c5fa51d 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -161,6 +161,31 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps - The administrative system is named ISAC. Separate navigation into explicit operational domains such as User Operations and Data Management; visibility must reflect server-provided capabilities. +- User Operations shows every supported authentication method as operational + metadata: local credentials, verified email, passkeys, Discord, and planned + providers. Identity-removal controls require `users.identities`, create audit + records, and preserve at least one remaining sign-in method. +- The Agent Registry prioritizes assigned roles over authentication-method + summaries. Authentication details belong in the individual agent dossier. +- ISAC authentication-method removal is always a two-step interaction contained + within the affected method row. The confirmation state explains the impact and + provides explicit Cancel and orange Confirm actions before submission. +- Agent dossiers consolidate operational controls into full-width tabs for + Roles & Permissions, Authentication, and Moderation. Keep dense controls in + their relevant tab instead of allowing one dashboard column to grow far beyond + adjacent content. +- Avatar removal uses the same in-card, two-step confirmation interaction as + authentication-method removal. +- Role and permission changes require a modal review step that summarizes the + complete selected role and direct-permission sets. Only the modal's orange + Confirm action submits the assignment. +- Access-review summaries are diffs: retained assignments use a neutral `KEEP` + state, additions use green `ADD`, and removals use red `REMOVE` with struck + assignment text. Added and removed items appear together in their respective + role or direct-permission column. +- Login, build/guide, and comment histories are independent support registries. + Each provides context-appropriate filters and namespaced pagination controls + so changing one history page does not reset the others. - Each module starts with `module-toolbar`, followed by `module-kpis`. - Editors use `dashboard-editor` and appear before the affected table. - Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`. diff --git a/src/app.css b/src/app.css index 099326e..f35010d 100644 --- a/src/app.css +++ b/src/app.css @@ -74,7 +74,7 @@ .provider-connection-card{position:relative;grid-template-columns:1fr auto!important;min-height:64px;overflow:hidden}.connection-state.provider-unlink-trigger{height:auto;padding:5px 7px;background:transparent;font-family:inherit;cursor:pointer;transition:var(--shd-transition)}.connection-state.provider-unlink-trigger span:last-child{display:none}.connection-state.provider-unlink-trigger:hover:not(:disabled),.connection-state.provider-unlink-trigger:focus-visible{border-color:var(--shd-danger);background:#281716;color:var(--shd-danger)}.connection-state.provider-unlink-trigger:hover:not(:disabled) span:first-child,.connection-state.provider-unlink-trigger:focus-visible span:first-child{display:none}.connection-state.provider-unlink-trigger:hover:not(:disabled) span:last-child,.connection-state.provider-unlink-trigger:focus-visible span:last-child{display:inline}.connection-state.provider-unlink-trigger:focus-visible{outline:2px solid var(--shd-danger);outline-offset:2px}.connection-state.provider-unlink-trigger:disabled{cursor:not-allowed;opacity:.55}.provider-unlink-confirm{position:absolute;z-index:2;inset:0;display:grid!important;grid-template-columns:1fr auto;align-items:center;gap:9px;padding:9px 10px;border:1px solid #9a5043;background:#211414}.provider-unlink-confirm>div:first-child{display:grid!important;gap:3px}.provider-unlink-confirm b{color:var(--shd-danger);font-size:8px;letter-spacing:.8px}.provider-unlink-confirm p{margin:0;color:#d6aaa1;font-size:7px;line-height:1.35}.provider-unlink-confirm>div:last-child{display:grid!important;grid-template-columns:74px 104px;gap:6px}.provider-unlink-confirm form,.provider-unlink-confirm fieldset{height:100%}.provider-unlink-confirm button{width:100%;height:32px;padding:0 7px;border-radius:0;appearance:none;font-size:6px}.provider-unlink-confirm .secondary{border:1px solid #565d5f;background:#101314;color:#d7d9d5}.provider-unlink-confirm .secondary:hover{border-color:#8a672b;color:var(--shd-orange)}.provider-unlink-confirm .primary{border:0;background:var(--shd-orange);color:#111}.provider-unlink-confirm .primary:hover{background:var(--shd-orange-hover)}.provider-card-message{grid-column:1/-1;margin:0} .linked-connection{grid-template-columns:1fr auto}.connection-state{padding:5px 7px;border:1px solid #3f6a4c;color:#75c08c;font-size:7px;font-weight:bold;letter-spacing:1px}.unlink-console{margin-top:8px}.connection-warning{margin:8px 0;color:#c29a60;font-size:8px;line-height:1.4} .isac-identity{margin:10px 0 14px}.isac-identity>.label{margin-bottom:6px}.identity-empty{padding:12px!important;border:1px dashed #343a3b} -.isac-avatar-review{margin:10px 0 14px}.isac-avatar-review>.label{margin-bottom:6px}.avatar-review-card{display:grid;grid-template-columns:58px 1fr auto;align-items:center;gap:10px;padding:8px;border:1px solid #303536;background:#0d1011}.avatar-review-card img{width:58px;height:58px;object-fit:cover;border:1px solid #5a4829}.avatar-review-card>div{display:grid;gap:4px}.avatar-review-card b{font-size:9px}.avatar-review-card small{color:#747c7e;font-size:7px;letter-spacing:.4px}.avatar-review-card>span{color:#75c08c;font-size:7px;letter-spacing:1px}.avatar-review-card>span.custom-avatar{color:#f6a821}.avatar-review-note{padding:9px 0 0} +.isac-avatar-review{margin:10px 0 14px}.isac-avatar-review>.label{margin-bottom:6px}.avatar-review-card{position:relative;display:grid;grid-template-columns:58px 1fr auto;align-items:center;gap:10px;padding:8px;border:1px solid #303536;background:#0d1011;overflow:hidden}.avatar-review-card img{width:58px;height:58px;object-fit:cover;border:1px solid #5a4829}.avatar-review-card>div{display:grid;gap:4px}.avatar-review-card b{font-size:9px}.avatar-review-card small{color:#747c7e;font-size:7px;letter-spacing:.4px}.avatar-review-card>span{color:#75c08c;font-size:7px;letter-spacing:1px}.avatar-review-card>span.custom-avatar{color:#f6a821}.avatar-review-card>.moderation-action{height:34px}.avatar-review-note{padding:9px 0 0}.avatar-review-card>.avatar-remove-confirm{display:grid;gap:8px} .moderation-action,.isac-control-link{position:relative;min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:9px;border-radius:0;font-size:8px;font-weight:900;letter-spacing:1.15px;text-transform:uppercase;cursor:pointer;transition:border-color .14s ease,background-color .14s ease,color .14s ease,transform .08s ease,filter .14s ease}.moderation-action.secondary,.isac-control-link{border:1px solid #424849;background:#101314;color:#d7d9d5}.moderation-action.secondary:hover,.isac-control-link:hover{border-color:#8a672b;background:#181711;color:#f6a821}.moderation-action.danger{border:1px solid #70423b;background:#211414;color:#ee8b78}.moderation-action.danger:hover{border-color:#d06d58;background:#321a17;color:#ffad9d}.moderation-action:focus-visible,.isac-control-link:focus-visible{outline:2px solid #f6a821;outline-offset:3px}.moderation-action:not(:disabled):active,.isac-control-link:active{transform:translateY(2px) scale(.985);filter:brightness(.86)}.moderation-action:disabled{border-color:#343839;background:#121415;color:#5f6668;cursor:not-allowed;opacity:.65}.moderation-panel fieldset[aria-busy=true] .moderation-action{cursor:wait;padding-right:34px}.moderation-panel fieldset[aria-busy=true] .moderation-action:after{content:"";position:absolute;right:12px;width:10px;height:10px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite} @keyframes interaction-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.moderation-action,.isac-control-link{transition:none}.moderation-action:not(:disabled):active,.isac-control-link:active{transform:none}.moderation-panel fieldset[aria-busy=true] .moderation-action:after,.discord-link-button[aria-busy="true"]:after{animation-duration:1.4s}} .identity-note{display:block;margin-top:5px;color:#687073;font-size:7px;line-height:1.4} @@ -88,9 +88,12 @@ .talent-row{grid-template-columns:minmax(150px,.8fr) minmax(105px,.45fr) minmax(260px,1.35fr) minmax(260px,1.35fr) 80px 45px;min-width:980px}.talent-row:not(.table-heading){align-items:start;padding-top:10px;padding-bottom:10px}.talent-description{margin:0;color:#b7bcba;font-size:9px;line-height:1.45;white-space:normal}.talent-description.unconfigured{color:#df8069;font-size:7px;letter-spacing:.8px}.talent-row .state-dot{margin-top:2px}.talent-row>button{margin-top:-3px} .talent-kpis{grid-template-columns:repeat(6,minmax(105px,1fr))} @media(max-width:1200px){.talent-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:650px){.talent-kpis{grid-template-columns:repeat(2,minmax(105px,1fr))}} -.section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1020px;grid-template-columns:1.1fr .75fr 1.1fr 1.1fr 1fr .3fr}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} +.section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1160px;grid-template-columns:1.1fr .65fr 1.1fr 1fr 1fr .9fr .3fr}.auth-method-summary{display:flex;flex-wrap:wrap;gap:3px}.auth-method-summary b{padding:3px 4px;border:1px solid #3a4840;color:#75c08c;font-size:5px;letter-spacing:.5px}.user-row>span>small{display:block;margin-bottom:3px;color:#f6a821;font-size:6px}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.isac-auth-method-list{display:grid;border:1px solid #303536}.isac-auth-method{position:relative;min-height:45px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:6px;padding:7px 8px;border-top:1px solid #292e2f;background:#0d1011;overflow:hidden}.isac-auth-method:first-child{border-top:0}.isac-auth-method>div{display:grid;gap:3px}.isac-auth-method b{font-size:8px}.isac-auth-method small{color:#697174;font-size:6px;line-height:1.35}.isac-auth-method>span{color:#626a6c;font-size:6px;font-weight:900;letter-spacing:.8px}.isac-auth-method>span.linked{color:#75c08c}.isac-auth-method.planned{opacity:.55}.isac-auth-method form,.isac-auth-method fieldset{height:100%}.isac-auth-method button{min-height:28px;height:100%;padding:0 7px;font-size:6px}.isac-method-confirm{position:absolute;z-index:2;inset:0;display:grid!important;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:6px 8px;background:#211414;border:1px solid #9a5043}.isac-method-confirm>div:first-child{display:grid;gap:3px}.isac-method-confirm>div:first-child b{color:var(--shd-danger)}.isac-method-confirm>div:last-child{height:30px;display:grid;grid-template-columns:60px 96px;gap:5px}.isac-method-confirm .secondary{border:1px solid #565d5f;background:#101314;color:#d7d9d5}.isac-method-confirm .primary{background:var(--shd-orange);color:#111}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} @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}} @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} .assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:6px;letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:7px;cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:7px}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:6px;letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:9px}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:6px;letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:8px;cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:6px;letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:7px;letter-spacing:1px} .registry-pagination{width:100%;height:auto;min-height:48px;margin:8px 0 0;padding:7px 9px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;border:1px solid #303536;border-bottom:0;background:#0f1213}.pagination-range{display:flex;align-items:center;gap:6px;color:#697174;font-size:7px;letter-spacing:.8px}.pagination-range b{color:#d7d9d5;font-size:9px}.pagination-pages{display:flex;align-items:center;justify-content:center;gap:4px}.pagination-pages a,.pagination-sizes a{min-width:30px;height:30px;display:grid;place-items:center;border:1px solid #343a3b;background:#111415;color:#8c9395;font-size:7px;font-weight:900;letter-spacing:.5px;cursor:pointer}.pagination-pages a:first-child,.pagination-pages a:last-child{min-width:58px;padding:0 8px}.pagination-pages a:hover,.pagination-pages a:focus-visible,.pagination-sizes a:hover,.pagination-sizes a:focus-visible{border-color:#8a672b;color:#f6a821}.pagination-pages a.active,.pagination-sizes a.active{border-color:#f6a821;background:#211a0e;color:#f6a821}.pagination-pages a:active,.pagination-sizes a:active{transform:translateY(1px);filter:brightness(.88)}.pagination-pages a.disabled{opacity:.35;pointer-events:none}.pagination-gap{color:#555d5f;font-size:8px}.pagination-sizes{display:flex;align-items:center;gap:4px}.pagination-sizes>span{margin-right:3px;color:#697174;font-size:7px;letter-spacing:1px}.pagination-sizes a{min-width:34px}.registry-pagination a:focus-visible{outline:2px solid #f6a821;outline-offset:2px} diff --git a/src/lib/components/RegistryPagination.svelte b/src/lib/components/RegistryPagination.svelte index 3cb036b..6f3b8f8 100644 --- a/src/lib/components/RegistryPagination.svelte +++ b/src/lib/components/RegistryPagination.svelte @@ -1,10 +1,10 @@ User operations — ISAC -

USER OPERATIONS

AGENT REGISTRY

Identity, access state, recent activity, and community contribution overview.

+

USER OPERATIONS

AGENT REGISTRY

Identity, authentication methods, access state, recent activity, and community contribution overview.

RESULTS{data.summary.results}Current query
ACTIVE{data.summary.active}Active within 30 days
INACTIVE{data.summary.inactive}No activity for 30 days
RESTRICTED{data.summary.restricted}Blocked or banned
-
USERACCESSLAST LOGINLAST ACTIVECONTENT
- {#each data.users as user}
{user.display_name}LOGIN: {user.username ?? 'NOT CONFIGURED'} · {user.discord_id ? 'DISCORD LINKED' : 'ACCOUNT IDENTITY'}
{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}{date(user.last_login_at)}{date(user.last_active_at)}{user.build_count} BUILDS · {user.comment_count} COMMENTSVIEW
+
USERACCESSROLESLAST LOGINLAST ACTIVECONTENT
+ {#each data.users as user}
{user.display_name}LOGIN: {user.username ?? 'NOT CONFIGURED'}
{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}
{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}{role.toUpperCase()}{/each}
{user.last_login_provider?.toUpperCase() ?? 'NONE'}{date(user.last_login_at)}{date(user.last_active_at)}{user.build_count} BUILDS · {user.comment_count} COMMENTSVIEW
{:else}
NO USERS FOUNDAdjust the username search and try again.
{/each}
diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index b0e2e63..0cf0709 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -8,11 +8,32 @@ import type { Actions, PageServerLoad } from './$types'; 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'; -export const load: PageServerLoad = async ({ cookies, params }) => { +export const load: PageServerLoad = async ({ cookies, params, url }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.view'); const database = await getDatabase(); - const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods] = await Promise.all([ + 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 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)`; + const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)]; + const [loginCount, buildCount, commentCount] = await Promise.all([ + (await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs), + (await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs), + (await database.prepare(`SELECT COUNT(*) AS count FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere}`)).get(...commentArgs) + ]); + const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login'); + const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build'); + const commentPagination = getPagination(url, Number(commentCount?.count ?? 0), 'comment'); + const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods, emails, passkeys] = await Promise.all([ (await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name, users.role,users.discord_id,users.created_at, CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password, @@ -30,11 +51,13 @@ export const load: PageServerLoad = async ({ cookies, params }) => { (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 user_id=? ORDER BY created_at DESC LIMIT 30')).all(params.id), - (await database.prepare('SELECT id,title,slug,visibility,updated_at FROM builds WHERE owner_id=? ORDER BY updated_at DESC LIMIT 50')).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 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 comments.user_id=? ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id) + 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) ,getAuthenticationMethods(database, params.id) + ,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id) + ,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id) ]); if (!profile) error(404, 'User not found'); return { @@ -46,7 +69,9 @@ export const load: PageServerLoad = async ({ cookies, params }) => { permissions, assignedRoleIds: assignedRoles.map((row) => String(row.role_id)), directPermissionIds: directPermissions.map((row) => String(row.permission_id)), - logins, builds, comments, + logins, builds, comments, emails, passkeys, + historyFilters: { loginQuery, loginStatus, buildQuery, buildVisibility, commentQuery }, + historyPagination: { login: loginPagination, build: buildPagination, comment: commentPagination }, authenticationMethods, operatorPermissions: await getPermissions(operator), isSelf: operator.id === params.id @@ -159,5 +184,33 @@ export const actions: Actions = { await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)')) .run(randomUUID(), operator.id, 'user', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now); return { identityUnlinked: true }; + }, + removeEmail: async ({ cookies, params }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'users.identities'); + const database = await getDatabase(); + const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(params.id); + if (!email) return fail(409, { identityError: 'This user does not have a verified email sign-in.' }); + if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'email')) { + return fail(400, { identityError: 'Email cannot be removed because it is this user’s only sign-in method.' }); + } + await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(params.id); + await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(params.id); + await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)')) + .run(randomUUID(), operator.id, 'user', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString()); + return { emailRemoved: true }; + }, + removePasskey: async ({ cookies, request, params }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'users.identities'); + const passkeyId = String((await request.formData()).get('passkeyId') ?? ''); + const database = await getDatabase(); + const passkey = await (await database.prepare('SELECT id,name FROM passkeys WHERE id=? AND user_id=?')).get(passkeyId, params.id); + if (!passkey) return fail(404, { identityError: 'That passkey was not found for this user.' }); + if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'passkey')) { + return fail(400, { identityError: 'This passkey cannot be removed because it is the user’s only sign-in method.' }); + } + await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(passkeyId, params.id); + await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)')) + .run(randomUUID(), operator.id, 'user', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString()); + return { passkeyRemoved: true }; } }; diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index f5df3f0..076125a 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -1,20 +1,40 @@ {data.profile.display_name} — ISAC user operations

AGENT DOSSIER

{data.profile.display_name}

Private login: {data.profile.username ?? 'Not configured'} · Created {date(data.profile.created_at)} · {data.authenticationMethods.total} sign-in method{data.authenticationMethods.total === 1 ? '' : 's'}

← USER REGISTRY
-
ACCESS STATE{accountState()}{data.profile.moderation_reason ?? 'No restrictions'}
LAST ACTIVE{date(data.profile.last_active_at)}Site activity
BUILDS{data.builds.length}Recent records loaded
COMMENTS{data.comments.length}Recent records loaded
+
ACCESS STATE{accountState()}{data.profile.moderation_reason ?? 'No restrictions'}
LAST ACTIVE{date(data.profile.last_active_at)}Site activity
BUILDS{data.historyPagination.build.total}Authored records
COMMENTS{data.historyPagination.comment.total}Posted records
+
+ + + +
-
+

ACCESS CONTROL

ROLES & PERMISSIONS

{#if can('users.roles')} @@ -22,42 +42,70 @@
{#each data.roles as role}{/each}
DIRECT PERMISSIONSUSER-SPECIFIC CAPABILITIES
{permissionIds.length} SELECTED
{#each data.permissions as permission}{/each}
- {#if form?.accessError}

{form.accessError}

{/if}
+ {#if form?.accessError}

{form.accessError}

{/if}{#if form?.accessSaved}

Roles and direct permissions updated.

{/if}
+ {#if accessConfirmationOpen} + + {/if}
{:else}

Your ISAC access permits viewing assignments but not changing them.

{/if}
-
-

ACCOUNT CONTROL

MODERATION

-
+
+

{operationsPanel === 'authentication' ? 'IDENTITY CONTROL' : 'ACCOUNT CONTROL'}

{operationsPanel === 'authentication' ? 'AUTHENTICATION' : 'MODERATION'}

+

AGENT AVATAR

{`${data.profile.display_name}'s
{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}
- {data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'} + {#if data.profile.avatar_source === 'upload' && can('users.moderate')}{:else}{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}{/if} + {#if removalTarget === 'avatar'}
REMOVE CUSTOM AVATAR?The uploaded file will be deleted and replaced by the default SHD portrait.
{/if}
{#if data.profile.avatar_source === 'upload' && can('users.moderate')} - -

Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.

- {#if form?.avatarModerationError}

{form.avatarModerationError}

{/if} - -
+

Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.

+ {#if form?.avatarModerationError}

{form.avatarModerationError}

{/if} {:else if data.profile.avatar_source === 'upload'}

Moderation permission is required to remove this custom image.

{/if} {#if form?.avatarRemoved}

Custom avatar removed and reset to the default SHD portrait.

{/if}
-
-

LINKED DISCORD IDENTITY

{#if data.profile.discord_id}
{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}
LINKED
{#if can('users.identities')}{#if data.authenticationMethods.total <= 1}

Discord is this agent’s only sign-in method and cannot be removed.

{/if}{#if form?.identityError}

{form.identityError}

{/if}
{/if}{:else}

NO DISCORD IDENTITY LINKED

{/if}
+
+
SIGN-IN METHODS{data.authenticationMethods.total} ACTIVE METHOD{data.authenticationMethods.total === 1 ? '' : 'S'}
+
+
LOCAL CREDENTIALS{data.authenticationMethods.local ? `PRIVATE LOGIN · ${data.profile.username}` : 'NOT CONFIGURED'}
{data.authenticationMethods.local ? 'ACTIVE' : 'NONE'}
+ {#each data.emails as email} +
EMAIL MAGIC LINK{email.email} · VERIFIED {date(email.verified_at)}
ACTIVE{#if can('users.identities')}{/if}{#if removalTarget === 'email'}
REMOVE EMAIL SIGN-IN?The agent will no longer receive access links at this address.
{/if}
+ {:else}
EMAIL MAGIC LINKNO VERIFIED EMAIL
NONE
{/each} + {#each data.passkeys as passkey} +
PASSKEY · {passkey.name}{passkey.device_type.toUpperCase()} · {Number(passkey.backed_up) ? 'SYNCED' : 'DEVICE-BOUND'} · LAST USED {date(passkey.last_used_at)}
ACTIVE{#if can('users.identities')}{/if}{#if removalTarget === `passkey:${passkey.id}`}
REMOVE THIS PASSKEY?{passkey.name} will no longer authenticate this agent.
{/if}
+ {:else}
PASSKEYNO PASSKEYS ENROLLED
NONE
{/each} +
DISCORD{data.profile.discord_display_name ? ` · ${data.profile.discord_display_name}` : ''}{data.profile.discord_id ? `${data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID ${data.profile.discord_id}` : 'NOT LINKED'}
{data.authenticationMethods.discord ? 'ACTIVE' : 'NONE'}{#if data.authenticationMethods.discord && can('users.identities')}{/if}{#if removalTarget === 'discord'}
UNLINK DISCORD?The agent will no longer be able to authenticate with this Discord identity.
{/if}
+ {#each ['Google', 'Apple', 'Steam'] as provider}
{provider.toUpperCase()}PROVIDER INTEGRATION PLANNED
PLANNED
{/each} +
+ {#if form?.identityError}

{form.identityError}

{/if} + {#if form?.emailRemoved}

Verified email sign-in removed.

{/if} + {#if form?.passkeyRemoved}

Passkey removed.

{/if} +
+

{#if can('users.moderate') && !data.profile.deleted_at} {#if data.isSelf}

ISAC will not permit self-restriction.

{/if}{#if form?.moderationError}

{form.moderationError}

{/if}
{:else}

Moderation permission is required to restrict this account.

{/if} {#if can('users.delete') && !data.profile.deleted_at}
SOFT-DELETE ACCOUNT

Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.

{#if form?.adminDeleteError}

{form.adminDeleteError}

{/if}
{/if} +
-
-
LOGIN HISTORY{data.logins.length} EVENTS
{#each data.logins as login}
{login.provider.toUpperCase()}{date(login.created_at)}{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}
{:else}

NO LOGIN EVENTS RECORDED

{/each}
-
BUILD & GUIDE HISTORY{data.builds.length} RECORDS
{#each data.builds as build}
{build.title}{date(build.updated_at)}{build.visibility.toUpperCase()}
{:else}

NO BUILDS CREATED

{/each}
-
COMMENT HISTORY{data.comments.length} RECORDS
{#each data.comments as comment}
{comment.title}{comment.body}{date(comment.created_at)}
{:else}

NO COMMENTS POSTED

{/each}
+
+
LOGIN HISTORY{data.historyPagination.login.total} EVENTS
{#each data.logins as login}
{login.provider.toUpperCase()}{date(login.created_at)}{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}
{:else}

NO LOGIN EVENTS MATCH THESE FILTERS

{/each}
+
BUILD & GUIDE HISTORY{data.historyPagination.build.total} RECORDS
{#each data.builds as build}
{build.title}{date(build.updated_at)}{build.visibility.toUpperCase()}
{:else}

NO BUILDS MATCH THESE FILTERS

{/each}
+
COMMENT HISTORY{data.historyPagination.comment.total} RECORDS
{#each data.comments as comment}
{comment.title}{comment.body}{date(comment.created_at)}
{:else}

NO COMMENTS MATCH THIS SEARCH

{/each}