Add unified account creation flow
This commit is contained in:
@@ -145,6 +145,15 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
|
|||||||
- Provider buttons still require SHD.build's focus-visible, pressed, pending, and
|
- Provider buttons still require SHD.build's focus-visible, pressed, pending, and
|
||||||
duplicate-interaction protection where those states do not conflict with the
|
duplicate-interaction protection where those states do not conflict with the
|
||||||
provider's branding requirements.
|
provider's branding requirements.
|
||||||
|
- Authentication uses one unified access screen rather than separate sign-in and
|
||||||
|
registration destinations. Verified email and first-time OAuth identities may
|
||||||
|
create an account; returning identities sign into the matching account.
|
||||||
|
- Password access uses an explicit `SIGN IN` / `CREATE ACCOUNT` mode switch
|
||||||
|
inside the fallback panel. Account creation separates the public display name
|
||||||
|
from the private ASCII login username.
|
||||||
|
- Passkeys are a returning-user method on this screen. New agents first establish
|
||||||
|
an account through verified email, OAuth, or local credentials, then enroll a
|
||||||
|
passkey from Account Access.
|
||||||
|
|
||||||
### Admin dashboard
|
### Admin dashboard
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -37,7 +37,7 @@
|
|||||||
.featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:10px;letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:9px;letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:9px;color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:8px;color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:8px;border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:8px;color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821}
|
.featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:10px;letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:9px;letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:9px;color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:8px;color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:8px;border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:8px;color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821}
|
||||||
.app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:9px;color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{color:#999;font-size:11px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:11px;margin:14px 0}.mini span{color:#888}.notice{font-size:11px;line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px}
|
.app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:9px;color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{color:#999;font-size:11px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:11px;margin:14px 0}.mini span{color:#888}.notice{font-size:11px;line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px}
|
||||||
.admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:11px;color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:11px}.data-row.labels{font-size:8px;color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:9px}.status{color:#73bd8a;font-size:8px}
|
.admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:11px;color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:11px}.data-row.labels{font-size:8px;color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:9px}.status{color:#73bd8a;font-size:8px}
|
||||||
.auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:11px;font-weight:bold;letter-spacing:1px}.divider{text-align:center;border-top:1px solid #333;margin:30px 0 20px}.divider span{position:relative;top:-7px;background:#141718;padding:0 12px;font-size:8px;color:#666}.auth-page label{display:block;font-size:9px;letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:11px;font-weight:bold}
|
.auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:11px;font-weight:bold;letter-spacing:1px}.divider{display:flex;align-items:center;gap:12px;margin:30px 0 20px;text-align:center}.divider:before,.divider:after{content:"";height:1px;flex:1;background:#333}.divider span{flex:0 0 auto;font-size:8px;color:#666}.auth-page label{display:block;font-size:9px;letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:11px;font-weight:bold}
|
||||||
@media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}}
|
@media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}}
|
||||||
|
|
||||||
/* Routed application compatibility: preserve the original SHD visual system. */
|
/* Routed application compatibility: preserve the original SHD visual system. */
|
||||||
@@ -62,6 +62,8 @@
|
|||||||
.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px}
|
.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px}
|
||||||
.auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px}
|
.auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px}
|
||||||
.signin-provider-rail{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.signin-provider{position:relative;min-width:0;min-height:76px;margin:0;padding:8px 4px 7px;display:grid;grid-template-rows:34px auto;place-items:center;gap:5px;border:1px solid #424849;border-radius:0;background:#101314;color:#dfe1e2;font-family:Arial,Helvetica,sans-serif;font-size:8px;font-style:normal;font-weight:800;letter-spacing:.3px;text-transform:none;cursor:pointer;transition:var(--shd-transition)}.signin-provider-icon{width:34px;height:34px;display:grid;place-items:center}.signin-provider-icon img{display:block;width:21px;height:21px;object-fit:contain}.signin-provider em{position:absolute;right:3px;top:3px;color:#737a7c;font-size:5px;font-style:normal;font-weight:900;letter-spacing:.5px;text-transform:uppercase}.signin-provider:hover:not(:disabled){transform:translateY(-1px);filter:brightness(1.12)}.signin-provider:active:not(:disabled){transform:translateY(2px) scale(.97)}.signin-provider:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.signin-provider:disabled{cursor:not-allowed;opacity:.46;filter:saturate(.55)}.discord-provider{border-color:#5865f2;background:#5865f2;color:#fff}.discord-provider:hover{background:#4752c4;filter:none!important}.discord-provider .signin-provider-icon img{width:25px;height:19px}.google-signin-provider{border-color:#8e918f;background:#131314;color:#e3e3e3}.google-signin-provider .signin-provider-icon{background:#fff}.apple-signin-provider{border-color:#73777a;background:#000;color:#fff}.apple-signin-provider .signin-provider-icon img{width:22px;height:22px}.steam-signin-provider{border-color:#66c0f4;background:#171d25;color:#fff}.steam-signin-provider .signin-provider-icon{color:#fff}.steam-signin-provider .signin-provider-icon img{width:25px;height:25px}
|
.signin-provider-rail{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.signin-provider{position:relative;min-width:0;min-height:76px;margin:0;padding:8px 4px 7px;display:grid;grid-template-rows:34px auto;place-items:center;gap:5px;border:1px solid #424849;border-radius:0;background:#101314;color:#dfe1e2;font-family:Arial,Helvetica,sans-serif;font-size:8px;font-style:normal;font-weight:800;letter-spacing:.3px;text-transform:none;cursor:pointer;transition:var(--shd-transition)}.signin-provider-icon{width:34px;height:34px;display:grid;place-items:center}.signin-provider-icon img{display:block;width:21px;height:21px;object-fit:contain}.signin-provider em{position:absolute;right:3px;top:3px;color:#737a7c;font-size:5px;font-style:normal;font-weight:900;letter-spacing:.5px;text-transform:uppercase}.signin-provider:hover:not(:disabled){transform:translateY(-1px);filter:brightness(1.12)}.signin-provider:active:not(:disabled){transform:translateY(2px) scale(.97)}.signin-provider:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.signin-provider:disabled{cursor:not-allowed;opacity:.46;filter:saturate(.55)}.discord-provider{border-color:#5865f2;background:#5865f2;color:#fff}.discord-provider:hover{background:#4752c4;filter:none!important}.discord-provider .signin-provider-icon img{width:25px;height:19px}.google-signin-provider{border-color:#8e918f;background:#131314;color:#e3e3e3}.google-signin-provider .signin-provider-icon{background:#fff}.apple-signin-provider{border-color:#73777a;background:#000;color:#fff}.apple-signin-provider .signin-provider-icon img{width:22px;height:22px}.steam-signin-provider{border-color:#66c0f4;background:#171d25;color:#fff}.steam-signin-provider .signin-provider-icon{color:#fff}.steam-signin-provider .signin-provider-icon img{width:25px;height:25px}
|
||||||
|
.auth-mode-note{margin:-3px 0 14px;color:#7b8385;font-size:9px;line-height:1.5}.auth-mode-switch{display:grid;grid-template-columns:1fr 1fr;margin:12px 0 4px;border:1px solid #383d3e}.auth-mode-switch button{height:34px;border:0;border-left:1px solid #383d3e;background:#0d1011;color:#747c7e;font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.auth-mode-switch button:first-child{border-left:0}.auth-mode-switch button.active{background:#2b2111;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.auth-mode-switch button:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}
|
||||||
|
.auth-page .auth-console form .full + .identity-note{margin-top:-14px}
|
||||||
.passkey-signin{position:relative;min-height:48px;margin-top:18px}.passkey-signin[aria-busy=true]{padding-right:42px}.passkey-signin[aria-busy=true]:after{content:"";position:absolute;right:16px;width:12px;height:12px;border:2px solid #111;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite}.auth-note{margin:0;color:var(--shd-text-muted);font-size:8px}.password-fallback{margin:18px 0;border:1px solid var(--shd-border);background:#101314}.password-fallback summary{padding:12px;color:#8e9698;font-size:8px;font-weight:900;letter-spacing:1px;cursor:pointer}.password-fallback summary:hover{color:var(--shd-orange)}.password-fallback form{padding:0 12px 12px}.password-fallback .full{margin-bottom:0}
|
.passkey-signin{position:relative;min-height:48px;margin-top:18px}.passkey-signin[aria-busy=true]{padding-right:42px}.passkey-signin[aria-busy=true]:after{content:"";position:absolute;right:16px;width:12px;height:12px;border:2px solid #111;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite}.auth-note{margin:0;color:var(--shd-text-muted);font-size:8px}.password-fallback{margin:18px 0;border:1px solid var(--shd-border);background:#101314}.password-fallback summary{padding:12px;color:#8e9698;font-size:8px;font-weight:900;letter-spacing:1px;cursor:pointer}.password-fallback summary:hover{color:var(--shd-orange)}.password-fallback form{padding:0 12px 12px}.password-fallback .full{margin-bottom:0}
|
||||||
.builder-page{padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:7px;letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821}
|
.builder-page{padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:7px;letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821}
|
||||||
@media(max-width:900px){.filter-bar{grid-template-columns:1fr 1fr}.catalogue-hero{align-items:flex-start}.guide-layout,.profile-dashboard,.auth-frame{grid-template-columns:1fr}.profile-command{align-items:flex-start}.profile-metrics{display:grid;grid-template-columns:1fr 1fr}.account-console{order:-1}.auth-intel{border-right:0!important;border-bottom:1px solid #303536!important;padding:42px!important}.auth-intel h1{font-size:52px!important}}@media(max-width:560px){.catalogue-hero,.profile-command{flex-direction:column;align-items:flex-start}.catalogue-count{text-align:left;margin-top:20px}.catalogue-count .primary{justify-self:start}.filter-bar{grid-template-columns:1fr}.guide-meta{position:static;margin-top:25px;flex-wrap:wrap}.profile-metrics{width:100%;margin-top:20px}.profile-columns{grid-template-columns:1fr}.auth-page{padding:25px 14px}.auth-intel,.auth-console{padding:28px!important}}
|
@media(max-width:900px){.filter-bar{grid-template-columns:1fr 1fr}.catalogue-hero{align-items:flex-start}.guide-layout,.profile-dashboard,.auth-frame{grid-template-columns:1fr}.profile-command{align-items:flex-start}.profile-metrics{display:grid;grid-template-columns:1fr 1fr}.account-console{order:-1}.auth-intel{border-right:0!important;border-bottom:1px solid #303536!important;padding:42px!important}.auth-intel h1{font-size:52px!important}}@media(max-width:560px){.catalogue-hero,.profile-command{flex-direction:column;align-items:flex-start}.catalogue-count{text-align:left;margin-top:20px}.catalogue-count .primary{justify-self:start}.filter-bar{grid-template-columns:1fr}.guide-meta{position:static;margin-top:25px;flex-wrap:wrap}.profile-metrics{width:100%;margin-top:20px}.profile-columns{grid-template-columns:1fr}.auth-page{padding:25px 14px}.auth-intel,.auth-console{padding:28px!important}}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createSession, destroySession, recordLogin } from '$lib/server/auth/session';
|
import { createSession, destroySession, recordLogin } from '$lib/server/auth/session';
|
||||||
import { verifyPassword } from '$lib/server/auth/password';
|
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||||
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
||||||
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
|
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => ({
|
export const load: PageServerLoad = async ({ url }) => ({
|
||||||
@@ -24,17 +25,68 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
if (!emailDeliveryConfigured()) return fail(503, { magicLinkError: 'Email sign-in is not configured yet.', email });
|
if (!emailDeliveryConfigured()) return fail(503, { magicLinkError: 'Email sign-in is not configured yet.', email });
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const identity = await (await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
|
let identity = await (await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
|
||||||
FROM user_emails LEFT JOIN magic_link_tokens ON magic_link_tokens.user_id=user_emails.user_id
|
FROM user_emails LEFT JOIN magic_link_tokens ON magic_link_tokens.user_id=user_emails.user_id
|
||||||
AND magic_link_tokens.purpose='login' AND magic_link_tokens.consumed_at IS NULL
|
AND magic_link_tokens.purpose='login' AND magic_link_tokens.consumed_at IS NULL
|
||||||
WHERE user_emails.email=? COLLATE NOCASE ORDER BY magic_link_tokens.created_at DESC LIMIT 1`)).get(email);
|
WHERE user_emails.email=? COLLATE NOCASE ORDER BY magic_link_tokens.created_at DESC LIMIT 1`)).get(email);
|
||||||
|
let purpose: 'login' | 'verify_email' = 'login';
|
||||||
|
if (!identity?.user_id) {
|
||||||
|
identity = await (await database.prepare(`SELECT user_id,created_at FROM magic_link_tokens
|
||||||
|
WHERE email=? COLLATE NOCASE AND purpose='verify_email' AND consumed_at IS NULL AND expires_at>?
|
||||||
|
ORDER BY created_at DESC LIMIT 1`)).get(email, new Date().toISOString());
|
||||||
|
purpose = 'verify_email';
|
||||||
|
}
|
||||||
|
if (!identity?.user_id) {
|
||||||
|
const id = randomUUID(), now = new Date().toISOString();
|
||||||
|
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
|
||||||
|
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
|
||||||
|
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||||
|
.run(id, 'New Agent', now);
|
||||||
|
identity = { user_id: id, created_at: null };
|
||||||
|
purpose = 'verify_email';
|
||||||
|
}
|
||||||
const recent = identity?.created_at && Date.now() - new Date(String(identity.created_at)).getTime() < 60_000;
|
const recent = identity?.created_at && Date.now() - new Date(String(identity.created_at)).getTime() < 60_000;
|
||||||
if (identity?.user_id && !recent) {
|
if (identity?.user_id && !recent) {
|
||||||
const token = await createMagicLinkToken(database, String(identity.user_id), email, 'login');
|
const token = await createMagicLinkToken(database, String(identity.user_id), email, purpose);
|
||||||
await sendAuthenticationLink(email, token, 'login');
|
await sendAuthenticationLink(email, token, purpose);
|
||||||
}
|
}
|
||||||
return { magicLinkSent: true };
|
return { magicLinkSent: true };
|
||||||
},
|
},
|
||||||
|
register: async ({ request, cookies }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim();
|
||||||
|
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
const confirm = String(form.get('confirmPassword') ?? '');
|
||||||
|
const displayLength = Array.from(displayName).length;
|
||||||
|
const values = { registerDisplayName: displayName, registerUsername: username };
|
||||||
|
if (displayLength < 2 || displayLength > 40 || /[\p{C}\r\n]/u.test(displayName)) {
|
||||||
|
return fail(400, { registerError: 'Use a display name with 2–40 visible characters.', ...values });
|
||||||
|
}
|
||||||
|
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) {
|
||||||
|
return fail(400, { registerError: 'Use a login username with 3–32 ASCII letters, numbers, underscores, or hyphens.', ...values });
|
||||||
|
}
|
||||||
|
if (['admin', 'administrator', 'moderator', 'shd', 'support'].includes(username.toLowerCase())) {
|
||||||
|
return fail(400, { registerError: 'That login username is reserved.', ...values });
|
||||||
|
}
|
||||||
|
if (password.length < 12 || password.length > 128) {
|
||||||
|
return fail(400, { registerError: 'Use a password between 12 and 128 characters.', ...values });
|
||||||
|
}
|
||||||
|
if (password !== confirm) return fail(400, { registerError: 'The passwords do not match.', ...values });
|
||||||
|
const database = await getDatabase();
|
||||||
|
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) {
|
||||||
|
return fail(409, { registerError: 'That login username is already in use.', ...values });
|
||||||
|
}
|
||||||
|
const id = randomUUID(), now = new Date().toISOString();
|
||||||
|
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
|
||||||
|
VALUES(?,?,?,?,?,?)`)).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, username, await hashPassword(password), 'user', now);
|
||||||
|
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||||
|
.run(id, displayName, now);
|
||||||
|
await ensureBootstrapAdmin(database, id);
|
||||||
|
await createSession(cookies, id);
|
||||||
|
await recordLogin(id, 'password', true);
|
||||||
|
redirect(303, '/account');
|
||||||
|
},
|
||||||
login: async ({ request, cookies }) => {
|
login: async ({ request, cookies }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const username = String(form.get('username') ?? '').trim();
|
const username = String(form.get('username') ?? '').trim();
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
let passkeyPending = $state(false);
|
let passkeyPending = $state(false);
|
||||||
let passkeySupported = $state(true);
|
let passkeySupported = $state(true);
|
||||||
let passkeyError = $state('');
|
let passkeyError = $state('');
|
||||||
|
let passwordMode = $state<'signin' | 'create'>('signin');
|
||||||
|
let passwordPanelOpen = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.registerError) {
|
||||||
|
passwordMode = 'create';
|
||||||
|
passwordPanelOpen = true;
|
||||||
|
} else if (form?.loginError) {
|
||||||
|
passwordMode = 'signin';
|
||||||
|
passwordPanelOpen = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
onMount(() => passkeySupported = browserSupportsWebAuthn());
|
onMount(() => passkeySupported = browserSupportsWebAuthn());
|
||||||
async function signInWithPasskey() {
|
async function signInWithPasskey() {
|
||||||
if (passkeyPending) return;
|
if (passkeyPending) return;
|
||||||
@@ -39,11 +50,12 @@
|
|||||||
</script>
|
</script>
|
||||||
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
|
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
|
||||||
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div>
|
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div>
|
||||||
|
<p class="auth-mode-note">Sign in to an existing account or create one with email, Discord, or local credentials.</p>
|
||||||
<button class="primary full passkey-signin" disabled={!passkeySupported || passkeyPending} aria-busy={passkeyPending} onclick={signInWithPasskey}>{passkeyPending ? 'VERIFYING PASSKEY…' : 'SIGN IN WITH A PASSKEY →'}</button>
|
<button class="primary full passkey-signin" disabled={!passkeySupported || passkeyPending} aria-busy={passkeyPending} onclick={signInWithPasskey}>{passkeyPending ? 'VERIFYING PASSKEY…' : 'SIGN IN WITH A PASSKEY →'}</button>
|
||||||
{#if !passkeySupported}<p class="auth-note">This browser does not support passkeys.</p>{/if}
|
{#if !passkeySupported}<p class="auth-note">This browser does not support passkeys.</p>{/if}
|
||||||
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
|
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
|
||||||
<div class="divider"><span>OR EMAIL A ONE-TIME LINK</span></div>
|
<div class="divider"><span>OR EMAIL A ONE-TIME LINK</span></div>
|
||||||
<PendingForm action="?/magicLink"><label class="field">VERIFIED EMAIL<input name="email" type="email" value={form?.email ?? ''} autocomplete="email" placeholder="agent@example.com" required /></label>{#if form?.magicLinkError}<p class="error">{form.magicLinkError}</p>{/if}{#if form?.magicLinkSent || data.magicLinkSent}<p class="success">If that email is linked to an agent, a sign-in link is on its way.</p>{/if}{#if data.magicError}<p class="error">{data.magicError}</p>{/if}<button class="secondary full" disabled={!data.emailDeliveryConfigured}>EMAIL MAGIC LINK →</button></PendingForm>
|
<PendingForm action="?/magicLink"><label class="field">VERIFIED EMAIL<input name="email" type="email" value={form?.email ?? ''} autocomplete="email" placeholder="agent@example.com" required /></label>{#if form?.magicLinkError}<p class="error">{form.magicLinkError}</p>{/if}{#if form?.magicLinkSent || data.magicLinkSent}<p class="success">Your one-time access link is on its way.</p>{/if}{#if data.magicError}<p class="error">{data.magicError}</p>{/if}<button class="secondary full" disabled={!data.emailDeliveryConfigured}>EMAIL ACCESS LINK →</button><small class="identity-note">A verified link signs you in, or creates your SHD.build account if this email is new.</small></PendingForm>
|
||||||
<div class="divider"><span>OR CONTINUE WITH</span></div>
|
<div class="divider"><span>OR CONTINUE WITH</span></div>
|
||||||
<div class="signin-provider-rail" aria-label="External sign-in providers">
|
<div class="signin-provider-rail" aria-label="External sign-in providers">
|
||||||
<a class="signin-provider discord-provider" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}>
|
<a class="signin-provider discord-provider" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}>
|
||||||
@@ -67,6 +79,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}
|
{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}
|
||||||
<details class="password-fallback" open={Boolean(form?.loginError)}><summary>USE USERNAME AND PASSWORD</summary><PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username webauthn" placeholder="Agent handle" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}<button class="secondary full">SIGN IN WITH PASSWORD →</button></PendingForm></details>
|
<details class="password-fallback" bind:open={passwordPanelOpen}><summary>USE USERNAME AND PASSWORD</summary>
|
||||||
<small>Passkeys and one-time email links are preferred. Password sign-in remains available as an optional fallback.</small>
|
<div class="auth-mode-switch" role="group" aria-label="Password access mode">
|
||||||
|
<button type="button" class:active={passwordMode === 'signin'} onclick={() => passwordMode = 'signin'}>SIGN IN</button>
|
||||||
|
<button type="button" class:active={passwordMode === 'create'} onclick={() => passwordMode = 'create'}>CREATE ACCOUNT</button>
|
||||||
|
</div>
|
||||||
|
{#if passwordMode === 'signin'}
|
||||||
|
<PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username webauthn" placeholder="Private login username" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}<button class="secondary full">SIGN IN WITH PASSWORD →</button></PendingForm>
|
||||||
|
{:else}
|
||||||
|
<PendingForm action="?/register"><label class="field">DISPLAY NAME<input name="displayName" value={form?.registerDisplayName ?? ''} autocomplete="nickname" placeholder="Public agent name" required /></label><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={form?.registerUsername ?? ''} autocomplete="username" placeholder="ASCII letters, numbers, _ or -" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="new-password" placeholder="12–128 characters" minlength="12" maxlength="128" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" placeholder="Repeat password" minlength="12" maxlength="128" required /></label>{#if form?.registerError}<p class="error">{form.registerError}</p>{/if}<button class="secondary full">CREATE ACCOUNT →</button></PendingForm>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
|
<small>Passkeys are preferred for returning agents and can be added after account creation. Password sign-in remains optional.</small>
|
||||||
</section></div></main>
|
</section></div></main>
|
||||||
|
|||||||
Reference in New Issue
Block a user