Expand ISAC user operations
This commit is contained in:
@@ -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`.
|
||||
|
||||
+5
-2
@@ -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}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page as currentPage } from '$app/state';
|
||||
let { pagination }: { pagination: { page: number; perPage: number; total: number; pageCount: number } } = $props();
|
||||
let { pagination, namespace = '' }: { pagination: { page: number; perPage: number; total: number; pageCount: number }; namespace?: string } = $props();
|
||||
const href = (targetPage: number, perPage = pagination.perPage) => {
|
||||
const params = new URLSearchParams(currentPage.url.searchParams);
|
||||
params.set('page', String(targetPage));
|
||||
params.set('perPage', String(perPage));
|
||||
params.set(namespace ? `${namespace}Page` : 'page', String(targetPage));
|
||||
params.set(namespace ? `${namespace}PerPage` : 'perPage', String(perPage));
|
||||
return `${currentPage.url.pathname}?${params}`;
|
||||
};
|
||||
const visiblePages = $derived(
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { URL } from 'node:url';
|
||||
|
||||
export const PAGE_SIZE_PRESETS = [10, 25, 50, 100] as const;
|
||||
|
||||
export function getPagination(url: URL, total: number) {
|
||||
const requestedSize = Number(url.searchParams.get('perPage'));
|
||||
export function getPagination(url: URL, total: number, namespace = '') {
|
||||
const pageKey = namespace ? `${namespace}Page` : 'page';
|
||||
const perPageKey = namespace ? `${namespace}PerPage` : 'perPage';
|
||||
const requestedSize = Number(url.searchParams.get(perPageKey));
|
||||
const perPage = PAGE_SIZE_PRESETS.includes(requestedSize as (typeof PAGE_SIZE_PRESETS)[number]) ? requestedSize : 25;
|
||||
const pageCount = Math.max(1, Math.ceil(total / perPage));
|
||||
const requestedPage = Number(url.searchParams.get('page'));
|
||||
const requestedPage = Number(url.searchParams.get(pageKey));
|
||||
const page = Math.min(pageCount, Math.max(1, Number.isInteger(requestedPage) ? requestedPage : 1));
|
||||
return {
|
||||
page,
|
||||
|
||||
@@ -23,9 +23,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const users = await (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,
|
||||
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
|
||||
JOIN roles ON roles.id=user_roles.role_id WHERE user_roles.user_id=users.id) AS assigned_roles,
|
||||
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 provider FROM login_events WHERE user_id=users.id AND succeeded=1 ORDER BY created_at DESC LIMIT 1) AS last_login_provider,
|
||||
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
</script>
|
||||
<svelte:head><title>User operations — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, authentication methods, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div>
|
||||
<div class="module-kpis"><div><span>RESULTS</span><strong>{data.summary.results}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.summary.inactive}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.summary.restricted}</strong><small>Blocked or banned</small></div></div>
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'} · {user.discord_id ? 'DISCORD LINKED' : 'ACCOUNT IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>ROLES</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
let { data, form } = $props();
|
||||
let roleIds = $state<string[]>([]);
|
||||
let permissionIds = $state<string[]>([]);
|
||||
let removalTarget = $state<string | null>(null);
|
||||
let operationsPanel = $state<'access' | 'authentication' | 'moderation'>('access');
|
||||
let accessConfirmationOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (form?.accessSaved) {
|
||||
accessConfirmationOpen = false;
|
||||
roleIds = [...data.assignedRoleIds];
|
||||
permissionIds = [...data.directPermissionIds];
|
||||
}
|
||||
if (form?.identityError || form?.emailRemoved || form?.passkeyRemoved || form?.identityUnlinked) operationsPanel = 'authentication';
|
||||
else if (form?.moderationError || form?.moderationSaved || form?.avatarModerationError || form?.avatarRemoved || form?.adminDeleteError || form?.accountDeleted) operationsPanel = 'moderation';
|
||||
});
|
||||
onMount(() => { roleIds = [...data.assignedRoleIds]; permissionIds = [...data.directPermissionIds]; });
|
||||
const can = (permission: string) => data.operatorPermissions.includes('*') || data.operatorPermissions.includes(permission);
|
||||
const accessChange = (id: string, selected: string[], original: string[]) =>
|
||||
selected.includes(id) ? (original.includes(id) ? 'keep' : 'add') : 'remove';
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
const accountState = () => data.profile.deleted_at ? 'DELETED' : data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
|
||||
</script>
|
||||
<svelte:head><title>{data.profile.display_name} — ISAC user operations</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username ?? 'Not configured'} · Created {date(data.profile.created_at)} · {data.authenticationMethods.total} sign-in method{data.authenticationMethods.total === 1 ? '' : 's'}</p></div><a class="secondary button-link isac-control-link" href="/admin/users">← USER REGISTRY</a></div>
|
||||
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.builds.length}</strong><small>Recent records loaded</small></div><div><span>COMMENTS</span><strong>{data.comments.length}</strong><small>Recent records loaded</small></div></div>
|
||||
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.historyPagination.build.total}</strong><small>Authored records</small></div><div><span>COMMENTS</span><strong>{data.historyPagination.comment.total}</strong><small>Posted records</small></div></div>
|
||||
|
||||
<div class="isac-operation-tabs" role="tablist" aria-label="User operations">
|
||||
<button class:active={operationsPanel === 'access'} role="tab" aria-selected={operationsPanel === 'access'} onclick={() => operationsPanel = 'access'}>ROLES & PERMISSIONS</button>
|
||||
<button class:active={operationsPanel === 'authentication'} role="tab" aria-selected={operationsPanel === 'authentication'} onclick={() => operationsPanel = 'authentication'}>AUTHENTICATION <b>{data.authenticationMethods.total}</b></button>
|
||||
<button class:active={operationsPanel === 'moderation'} role="tab" aria-selected={operationsPanel === 'moderation'} onclick={() => operationsPanel = 'moderation'}>MODERATION</button>
|
||||
</div>
|
||||
<div class="user-console-grid">
|
||||
<section class="dashboard-editor">
|
||||
<section class="dashboard-editor" class:isac-panel-hidden={operationsPanel !== 'access'}>
|
||||
<div class="editor-title"><div><p class="eyebrow">ACCESS CONTROL</p><h3>ROLES & PERMISSIONS</h3></div></div>
|
||||
{#if can('users.roles')}
|
||||
<PendingForm action="?/setAccess">
|
||||
@@ -22,42 +42,70 @@
|
||||
<div class="selection-grid">{#each data.roles as role}<label><input type="checkbox" name="roleIds" value={role.id} bind:group={roleIds}/><span>{role.name}</span><small>{roleIds.includes(role.id) ? 'ASSIGNED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
<div class="selection-head"><div><span>DIRECT PERMISSIONS</span><small>USER-SPECIFIC CAPABILITIES</small></div><b>{permissionIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid permission-grid">{#each data.permissions as permission}<label title={permission.description}><input type="checkbox" name="permissionIds" value={permission.id} bind:group={permissionIds}/><span>{permission.name}</span><small>{permissionIds.includes(permission.id) ? 'GRANTED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
{#if form?.accessError}<p class="error">{form.accessError}</p>{/if}<div class="form-actions"><button class="primary">SAVE ACCESS →</button></div>
|
||||
{#if form?.accessError}<p class="error">{form.accessError}</p>{/if}{#if form?.accessSaved}<p class="success">Roles and direct permissions updated.</p>{/if}<div class="form-actions"><button type="button" class="primary" onclick={() => accessConfirmationOpen = true}>REVIEW ACCESS →</button></div>
|
||||
{#if accessConfirmationOpen}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="access-confirm-title" tabindex="-1">
|
||||
<div class="editor-title"><div><p class="eyebrow">CONFIRM ACCESS PROFILE</p><h3 id="access-confirm-title">{data.profile.display_name}</h3></div></div>
|
||||
<p class="console-note">Review the complete role and direct-permission assignment before applying it to this agent.</p>
|
||||
<div class="access-confirm-grid">
|
||||
<div><span>ROLE CHANGES</span>{#each data.roles.filter((role) => roleIds.includes(String(role.id)) || data.assignedRoleIds.includes(String(role.id))) as role}{@const state = accessChange(String(role.id), roleIds, data.assignedRoleIds)}<b class:access-add={state === 'add'} class:access-remove={state === 'remove'}><span>{role.name}</span><em>{state.toUpperCase()}</em></b>{:else}<small>NO ROLE ASSIGNMENTS</small>{/each}</div>
|
||||
<div><span>DIRECT PERMISSION CHANGES</span>{#each data.permissions.filter((permission) => permissionIds.includes(String(permission.id)) || data.directPermissionIds.includes(String(permission.id))) as permission}{@const state = accessChange(String(permission.id), permissionIds, data.directPermissionIds)}<b class:access-add={state === 'add'} class:access-remove={state === 'remove'} title={permission.description}><span>{permission.name}</span><em>{state.toUpperCase()}</em></b>{:else}<small>NO DIRECT PERMISSION ASSIGNMENTS</small>{/each}</div>
|
||||
</div>
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => accessConfirmationOpen = false}>CANCEL</button><button class="primary">CONFIRM ACCESS</button></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</PendingForm>
|
||||
{:else}<p class="console-note">Your ISAC access permits viewing assignments but not changing them.</p>{/if}
|
||||
</section>
|
||||
<section class="dashboard-editor moderation-panel">
|
||||
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
|
||||
<div class="isac-avatar-review">
|
||||
<section class="dashboard-editor moderation-panel" class:isac-panel-hidden={operationsPanel === 'access'}>
|
||||
<div class="editor-title"><div><p class="eyebrow">{operationsPanel === 'authentication' ? 'IDENTITY CONTROL' : 'ACCOUNT CONTROL'}</p><h3>{operationsPanel === 'authentication' ? 'AUTHENTICATION' : 'MODERATION'}</h3></div></div>
|
||||
<div class="isac-avatar-review" class:isac-panel-hidden={operationsPanel !== 'moderation'}>
|
||||
<p class="label">AGENT AVATAR</p>
|
||||
<div class="avatar-review-card">
|
||||
<img src={data.profile.avatar_url} alt={`${data.profile.display_name}'s avatar`}/>
|
||||
<div><b>{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}</b><small>{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}</small></div>
|
||||
<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>
|
||||
{#if data.profile.avatar_source === 'upload' && can('users.moderate')}<button type="button" class="danger moderation-action" onclick={() => removalTarget = 'avatar'}>REMOVE</button>{:else}<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>{/if}
|
||||
{#if removalTarget === 'avatar'}<div class="isac-method-confirm avatar-remove-confirm"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removeCustomAvatar"><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}
|
||||
</div>
|
||||
{#if data.profile.avatar_source === 'upload' && can('users.moderate')}
|
||||
<PendingForm action="?/removeCustomAvatar">
|
||||
<p class="console-note avatar-review-note">Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.</p>
|
||||
{#if form?.avatarModerationError}<p class="error">{form.avatarModerationError}</p>{/if}
|
||||
<button class="danger console-action moderation-action">REMOVE CUSTOM AVATAR</button>
|
||||
</PendingForm>
|
||||
<p class="console-note avatar-review-note">Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.</p>
|
||||
{#if form?.avatarModerationError}<p class="error">{form.avatarModerationError}</p>{/if}
|
||||
{:else if data.profile.avatar_source === 'upload'}
|
||||
<p class="console-note avatar-review-note">Moderation permission is required to remove this custom image.</p>
|
||||
{/if}
|
||||
{#if form?.avatarRemoved}<p class="success">Custom avatar removed and reset to the default SHD portrait.</p>{/if}
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="isac-identity"><p class="label">LINKED DISCORD IDENTITY</p>{#if data.profile.discord_id}<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}</b><small>{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}</small></div></div><span class="connection-state">LINKED</span></div>{#if can('users.identities')}<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if data.authenticationMethods.total <= 1}<p class="connection-warning">Discord is this agent’s only sign-in method and cannot be removed.</p>{/if}{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}<button class="secondary console-action moderation-action" disabled={data.authenticationMethods.total <= 1}>UNLINK DISCORD</button></PendingForm>{/if}{:else}<p class="console-note identity-empty">NO DISCORD IDENTITY LINKED</p>{/if}</div>
|
||||
<div class="isac-auth-methods" class:isac-panel-hidden={operationsPanel !== 'authentication'}>
|
||||
<div class="selection-head"><div><span>SIGN-IN METHODS</span><small>{data.authenticationMethods.total} ACTIVE METHOD{data.authenticationMethods.total === 1 ? '' : 'S'}</small></div></div>
|
||||
<div class="isac-auth-method-list">
|
||||
<div class="isac-auth-method"><div><b>LOCAL CREDENTIALS</b><small>{data.authenticationMethods.local ? `PRIVATE LOGIN · ${data.profile.username}` : 'NOT CONFIGURED'}</small></div><span class:linked={data.authenticationMethods.local}>{data.authenticationMethods.local ? 'ACTIVE' : 'NONE'}</span></div>
|
||||
{#each data.emails as email}
|
||||
<div class="isac-auth-method"><div><b>EMAIL MAGIC LINK</b><small>{email.email} · VERIFIED {date(email.verified_at)}</small></div><span class="linked">ACTIVE</span>{#if can('users.identities')}<button type="button" class="danger moderation-action" disabled={data.authenticationMethods.total <= 1} onclick={() => removalTarget = 'email'}>REMOVE</button>{/if}{#if removalTarget === 'email'}<div class="isac-method-confirm"><div><b>REMOVE EMAIL SIGN-IN?</b><small>The agent will no longer receive access links at this address.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removeEmail"><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}</div>
|
||||
{:else}<div class="isac-auth-method"><div><b>EMAIL MAGIC LINK</b><small>NO VERIFIED EMAIL</small></div><span>NONE</span></div>{/each}
|
||||
{#each data.passkeys as passkey}
|
||||
<div class="isac-auth-method"><div><b>PASSKEY · {passkey.name}</b><small>{passkey.device_type.toUpperCase()} · {Number(passkey.backed_up) ? 'SYNCED' : 'DEVICE-BOUND'} · LAST USED {date(passkey.last_used_at)}</small></div><span class="linked">ACTIVE</span>{#if can('users.identities')}<button type="button" class="danger moderation-action" disabled={data.authenticationMethods.total <= 1} onclick={() => removalTarget = `passkey:${passkey.id}`}>REMOVE</button>{/if}{#if removalTarget === `passkey:${passkey.id}`}<div class="isac-method-confirm"><div><b>REMOVE THIS PASSKEY?</b><small>{passkey.name} will no longer authenticate this agent.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removePasskey"><input type="hidden" name="passkeyId" value={passkey.id}/><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}</div>
|
||||
{:else}<div class="isac-auth-method"><div><b>PASSKEY</b><small>NO PASSKEYS ENROLLED</small></div><span>NONE</span></div>{/each}
|
||||
<div class="isac-auth-method"><div><b>DISCORD{data.profile.discord_display_name ? ` · ${data.profile.discord_display_name}` : ''}</b><small>{data.profile.discord_id ? `${data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID ${data.profile.discord_id}` : 'NOT LINKED'}</small></div><span class:linked={data.authenticationMethods.discord}>{data.authenticationMethods.discord ? 'ACTIVE' : 'NONE'}</span>{#if data.authenticationMethods.discord && can('users.identities')}<button type="button" class="danger moderation-action" disabled={data.authenticationMethods.total <= 1} onclick={() => removalTarget = 'discord'}>REMOVE</button>{/if}{#if removalTarget === 'discord'}<div class="isac-method-confirm"><div><b>UNLINK DISCORD?</b><small>The agent will no longer be able to authenticate with this Discord identity.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/unlinkDiscord"><button class="primary">CONFIRM UNLINK</button></PendingForm></div></div>{/if}</div>
|
||||
{#each ['Google', 'Apple', 'Steam'] as provider}<div class="isac-auth-method planned"><div><b>{provider.toUpperCase()}</b><small>PROVIDER INTEGRATION PLANNED</small></div><span>PLANNED</span></div>{/each}
|
||||
</div>
|
||||
{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}
|
||||
{#if form?.emailRemoved}<p class="success">Verified email sign-in removed.</p>{/if}
|
||||
{#if form?.passkeyRemoved}<p class="success">Passkey removed.</p>{/if}
|
||||
</div>
|
||||
<div class:isac-panel-hidden={operationsPanel !== 'moderation'}>
|
||||
<hr/>
|
||||
{#if can('users.moderate') && !data.profile.deleted_at}
|
||||
<PendingForm action="?/moderate"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger moderation-action" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
|
||||
{: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 moderation-action" disabled={data.isSelf}>DELETE ACCOUNT</button></PendingForm></details>{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="user-history-grid">
|
||||
<section><div class="history-head"><span>LOGIN HISTORY</span><b>{data.logins.length} EVENTS</b></div><div class="history-list">{#each data.logins as login}<div><strong>{login.provider.toUpperCase()}</strong><span>{date(login.created_at)}</span><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS RECORDED</p>{/each}</div></section>
|
||||
<section><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.builds.length} RECORDS</b></div><div class="history-list">{#each data.builds as build}<div><strong>{build.title}</strong><span>{date(build.updated_at)}</span><b>{build.visibility.toUpperCase()}</b></div>{:else}<p>NO BUILDS CREATED</p>{/each}</div></section>
|
||||
<section><div class="history-head"><span>COMMENT HISTORY</span><b>{data.comments.length} RECORDS</b></div><div class="history-list comment-history">{#each data.comments as comment}<div><strong>{comment.title}</strong><span>{comment.body}</span><b>{date(comment.created_at)}</b></div>{:else}<p>NO COMMENTS POSTED</p>{/each}</div></section>
|
||||
<div class="user-history-registries">
|
||||
<section class="history-registry"><div class="history-head"><span>LOGIN HISTORY</span><b>{data.historyPagination.login.total} EVENTS</b></div><form class="history-filter"><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="loginQ" value={data.historyFilters.loginQuery} placeholder="PROVIDER"/><select name="loginStatus"><option value="">ALL RESULTS</option><option value="success" selected={data.historyFilters.loginStatus === 'success'}>SUCCESSFUL</option><option value="failed" selected={data.historyFilters.loginStatus === 'failed'}>FAILED</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.login} namespace="login"/><div class="history-list">{#each data.logins as login}<div><strong>{login.provider.toUpperCase()}</strong><span>{date(login.created_at)}</span><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||
<section class="history-registry"><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.historyPagination.build.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="buildQ" value={data.historyFilters.buildQuery} placeholder="TITLE OR SLUG"/><select name="buildVisibility"><option value="">ALL VISIBILITY</option><option value="draft" selected={data.historyFilters.buildVisibility === 'draft'}>DRAFT</option><option value="private" selected={data.historyFilters.buildVisibility === 'private'}>PRIVATE</option><option value="public" selected={data.historyFilters.buildVisibility === 'public'}>PUBLIC</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.build} namespace="build"/><div class="history-list">{#each data.builds as build}<div><strong>{build.title}</strong><span>{date(build.updated_at)}</span><b>{build.visibility.toUpperCase()}</b></div>{:else}<p>NO BUILDS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||
<section class="history-registry"><div class="history-head"><span>COMMENT HISTORY</span><b>{data.historyPagination.comment.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input name="commentQ" value={data.historyFilters.commentQuery} placeholder="COMMENT OR BUILD TITLE"/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.comment} namespace="comment"/><div class="history-list comment-history">{#each data.comments as comment}<div><strong>{comment.title}</strong><span>{comment.body}</span><b>{date(comment.created_at)}</b></div>{:else}<p>NO COMMENTS MATCH THIS SEARCH</p>{/each}</div></section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user