|
|
@@ -38,7 +38,7 @@ import { |
| 38 | 38 |
btn, |
| 39 | 39 |
card, |
| 40 | 40 |
confirmAction, |
| 41 | |
empty, |
| 41 |
row, |
| 42 | 42 |
time, |
| 43 | 43 |
userLink, |
| 44 | 44 |
} from "../ui.ts"; |
|
|
@@ -71,7 +71,7 @@ function standing(a: AccountRow): Html { |
| 71 | 71 |
} |
| 72 | 72 |
|
| 73 | 73 |
|
| 74 | |
function row(a: AccountRow, sole: readonly string[]): Html { |
| 74 |
function personRow(a: AccountRow, sole: readonly string[]): Html { |
| 75 | 75 |
const at = `/settings/accounts/${encodeURIComponent(a.username)}`; |
| 76 | 76 |
const how = [ |
| 77 | 77 |
originOf(a), |
|
|
@@ -84,26 +84,17 @@ function row(a: AccountRow, sole: readonly string[]): Html { |
| 84 | 84 |
]; |
| 85 | 85 |
|
| 86 | 86 |
const actionable = a.kind === "user" && !a.isAdmin && a.removedAt === null; |
| 87 | |
return html`<li class="row"> |
| 88 | |
<div class="row-main"> |
| 89 | |
${userLink(a.username, { mark: markOf(a) })} ${standing(a)} |
| 90 | |
<div class="row-meta"> |
| 91 | |
joined |
| 92 | |
${time(a.createdAt)}${how.length > 0 |
| 93 | |
? html` · ${how.join(" · ")}` |
| 94 | |
: null} |
| 95 | |
· ${holds.join(" · ")}${sole.length > 0 && a.suspendedAt === null |
| 96 | |
? html` · only owner of ${sole.join(", ")}` |
| 97 | |
: null} |
| 98 | |
</div> |
| 99 | |
${sole.length > 0 && a.suspendedAt !== null |
| 100 | |
? html`<div class="row-meta"> |
| 101 | |
${badge(`${sole.join(", ")}: no active owner`, { tone: "danger" })} |
| 102 | |
</div>` |
| 103 | |
: null} |
| 104 | |
</div> |
| 105 | |
${actionable |
| 106 | |
? html`<div class="row-actions"> |
| 87 |
return row({ |
| 88 |
title: html`${userLink(a.username, { mark: markOf(a) })} ${standing(a)}`, |
| 89 |
meta: html`joined |
| 90 |
${time(a.createdAt)}${how.length > 0 ? html` · ${how.join(" · ")}` : null} |
| 91 |
· ${holds.join(" · ")}${sole.length > 0 && a.suspendedAt === null |
| 92 |
? html` · only owner of ${sole.join(", ")}` |
| 93 |
: null}${sole.length > 0 && a.suspendedAt !== null |
| 94 |
? html` · ${badge(`${sole.join(", ")}: no active owner`, { tone: "danger" })}` |
| 95 |
: null}`, |
| 96 |
...(actionable |
| 97 |
? { after: html` |
| 107 | 98 |
${btn("Sign-ins", { href: at, size: "sm", variant: "ghost" })} |
| 108 | 99 |
${a.verified |
| 109 | 100 |
? null |
|
|
@@ -111,22 +102,14 @@ function row(a: AccountRow, sole: readonly string[]): Html { |
| 111 | 102 |
${btn("Verify email", { type: "submit", size: "sm" })} |
| 112 | 103 |
</form>`} |
| 113 | 104 |
${a.suspendedAt === null |
| 114 | |
? confirmAction({ |
| 115 | |
action: `${at}/suspend`, |
| 116 | |
verb: "Suspend", |
| 117 | |
size: "sm", |
| 118 | |
}) |
| 105 |
? confirmAction({ action: `${at}/suspend`, verb: "Suspend", size: "sm" }) |
| 119 | 106 |
: html`<form method="post" action="${at}/unsuspend"> |
| 120 | 107 |
${btn("Unsuspend", { type: "submit", size: "sm" })} |
| 121 | 108 |
</form>`} |
| 122 | |
${confirmAction({ |
| 123 | |
action: `${at}/remove`, |
| 124 | |
verb: "Remove", |
| 125 | |
size: "sm", |
| 126 | |
})} |
| 127 | |
</div>` |
| 128 | |
: null} |
| 129 | |
</li>`; |
| 109 |
${confirmAction({ action: `${at}/remove`, verb: "Remove", size: "sm" })} |
| 110 |
` } |
| 111 |
: {}), |
| 112 |
}); |
| 130 | 113 |
} |
| 131 | 114 |
|
| 132 | 115 |
// GET /settings/accounts |
|
|
@@ -149,14 +132,14 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { |
| 149 | 132 |
const mail = timePhase(ctx.request, "mail", () => mailConfigured(ctx.app)); |
| 150 | 133 |
const noMail = mail |
| 151 | 134 |
? null |
| 152 | |
: card( |
| 153 | |
html`<p> |
| 154 | |
Mail is not configured, so newcomers cannot verify their email |
| 155 | |
themselves. Verify them here, or set <code>MAIL_SMTP_URL</code> and |
| 156 | |
<code>MAIL_FROM</code> under <a href="/settings/secrets">Secrets</a>. |
| 157 | |
</p>`, |
| 158 | |
{ tone: "attn" }, |
| 159 | |
); |
| 135 |
: html`<section class="settings-block" id="no-mail"> |
| 136 |
<h2>Mail is not configured</h2> |
| 137 |
<p> |
| 138 |
Newcomers cannot verify their email themselves. Verify them here, or |
| 139 |
set <code>MAIL_SMTP_URL</code> and <code>MAIL_FROM</code> under |
| 140 |
<a href="/settings/secrets">Secrets</a>. |
| 141 |
</p> |
| 142 |
</section>`; |
| 160 | 143 |
// An organization every owner of which is suspended or removed stays as |
| 161 | 144 |
// it is for its members; only the forge's owner can mend it. |
| 162 | 145 |
const orphans = timePhase(ctx.request, "orphans", () => |
|
|
@@ -165,8 +148,9 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { |
| 165 | 148 |
const ownerless = |
| 166 | 149 |
orphans.length === 0 |
| 167 | 150 |
? null |
| 168 | |
: card( |
| 169 | |
html`<p> |
| 151 |
: html`<section class="settings-block" id="ownerless"> |
| 152 |
<h2>No active owner</h2> |
| 153 |
<p> |
| 170 | 154 |
${orphans.map( |
| 171 | 155 |
(o, i) => html`${i > 0 ? ", " : ""}${userLink(o.username)}`, |
| 172 | 156 |
)} |
|
|
@@ -178,18 +162,17 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { |
| 178 | 162 |
>${o.username}'s Owners</a |
| 179 | 163 |
>`, |
| 180 | 164 |
)}. |
| 181 | |
</p>`, |
| 182 | |
{ tone: "attn" }, |
| 183 | |
); |
| 165 |
</p> |
| 166 |
</section>`; |
| 184 | 167 |
const reach = timePhase(ctx.request, "edge-only", () => signinReach(ctx.app.db)); |
| 185 | 168 |
const stranded = reach.stranded; |
| 186 | 169 |
const edgeOnly = |
| 187 | 170 |
stranded.length === 0 |
| 188 | |
? card( |
| 189 | |
html`<p id="signin-reach">All ${reach.total} accounts can sign in without the platform edge.</p>`, |
| 190 | |
) |
| 191 | |
: card( |
| 192 | |
html`<p id="signin-reach">${reach.own} of ${reach.total} accounts can sign in without the platform edge.</p> |
| 171 |
? html`<section class="settings-block"> |
| 172 |
<p id="signin-reach">All ${reach.total} accounts can sign in without the platform edge.</p> |
| 173 |
</section>` |
| 174 |
: html`<section class="settings-block"> |
| 175 |
<h2 id="signin-reach">${reach.own} of ${reach.total} accounts can sign in without the platform edge.</h2> |
| 193 | 176 |
<p> |
| 194 | 177 |
${stranded.length === 1 ? "This account signs" : "These accounts sign"} |
| 195 | 178 |
in only through the platform edge, so switching |
|
|
@@ -202,9 +185,8 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { |
| 202 | 185 |
? " (admin)" |
| 203 | 186 |
: ""}`, |
| 204 | 187 |
)}. |
| 205 | |
</p>`, |
| 206 | |
{ tone: "attn" }, |
| 207 | |
); |
| 188 |
</p> |
| 189 |
</section>`; |
| 208 | 190 |
const sole = timePhase(ctx.request, "owned", () => |
| 209 | 191 |
people.map((a) => { |
| 210 | 192 |
const u = getUserByName(ctx.app.db, a.username); |
|
|
@@ -216,21 +198,13 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { |
| 216 | 198 |
const rendering = performance.now(); |
| 217 | 199 |
const response = shell( |
| 218 | 200 |
ctx, |
| 219 | |
html`${notice ?? null}${ownerless}${edgeOnly}${noMail}${card( |
| 220 | |
people.length === 0 |
| 221 | |
? empty({ icon: "users", title: "Nobody has joined yet." }) |
| 222 | |
: html`<ul class="rows"> |
| 223 | |
${people.map((a, i) => row(a, sole[i] ?? []))} |
| 224 | |
</ul>`, |
| 225 | |
{ |
| 226 | |
head: html`<h2>Who is on this forge</h2> |
| 227 | |
<p class="muted"> |
| 228 | |
Newest first. You hear of each new account on your phone, if you set |
| 229 | |
a notify address. Suspending stops an account's sign-in and |
| 230 | |
everything its repositories run; removing deletes what it owns. |
| 231 | |
</p>`, |
| 232 | |
}, |
| 233 | |
)}`, |
| 201 |
html`${notice ?? null}${ownerless}${edgeOnly}${noMail}<section class="settings-block" id="accounts"> |
| 202 |
<h2>Who is on this forge</h2> |
| 203 |
<p class="muted">Newest first.</p> |
| 204 |
<ul class="list setting-list" id="accounts-list"> |
| 205 |
${people.map((a, i) => personRow(a, sole[i] ?? []))} |
| 206 |
</ul> |
| 207 |
</section>`, |
| 234 | 208 |
status, |
| 235 | 209 |
); |
| 236 | 210 |
notePhase(ctx.request, "render", performance.now() - rendering); |
|
|
@@ -244,8 +218,9 @@ const ownersTeam = (org: string): string => |
| 244 | 218 |
/** Before suspending the last active owner of an organization, say so once. */ |
| 245 | 219 |
function confirmSuspend(username: string, orgs: readonly string[]): Html { |
| 246 | 220 |
const at = `/settings/accounts/${encodeURIComponent(username)}`; |
| 247 | |
return card( |
| 248 | |
html`<p> |
| 221 |
return html`<section class="settings-block" id="confirm-suspend"> |
| 222 |
<h2>Suspend the only active owner?</h2> |
| 223 |
<p> |
| 249 | 224 |
<strong>${username}</strong> is the only active owner of |
| 250 | 225 |
${orgs.join(", ")}. Suspended, nobody can administer |
| 251 | 226 |
${orgs.length === 1 ? "it" : "them"} until they are unsuspended or |
|
|
@@ -263,15 +238,15 @@ function confirmSuspend(username: string, orgs: readonly string[]): Html { |
| 263 | 238 |
? null |
| 264 | 239 |
: btn("Add an owner first", { href: ownersTeam(orgs[0]), size: "sm" })} |
| 265 | 240 |
${btn("Keep them", { href: "/settings/accounts", size: "sm", variant: "ghost" })} |
| 266 | |
</form>`, |
| 267 | |
{ tone: "attn" }, |
| 268 | |
); |
| 241 |
</form> |
| 242 |
</section>`; |
| 269 | 243 |
} |
| 270 | 244 |
|
| 271 | 245 |
/** A removal refused for a sole-owned organization: the way through, once. */ |
| 272 | 246 |
function refuseRemove(username: string): Html { |
| 273 | |
return card( |
| 274 | |
html`<p> |
| 247 |
return html`<section class="settings-block" id="refuse-remove"> |
| 248 |
<h2>Not removed</h2> |
| 249 |
<p> |
| 275 | 250 |
<strong>${username}</strong> is the only owner of an organization |
| 276 | 251 |
(named in their row). Make someone else an owner first, or remove the |
| 277 | 252 |
organization too after seeing everything that would go. Nothing was |
|
|
@@ -283,9 +258,8 @@ function refuseRemove(username: string): Html { |
| 283 | 258 |
size: "sm", |
| 284 | 259 |
variant: "danger", |
| 285 | 260 |
})} |
| 286 | |
</div>`, |
| 287 | |
{ tone: "attn" }, |
| 288 | |
); |
| 261 |
</div> |
| 262 |
</section>`; |
| 289 | 263 |
} |
| 290 | 264 |
|
| 291 | 265 |
// GET /settings/accounts/:username — where one person is signed in |
|
|
@@ -441,8 +415,9 @@ export function retirePage(ctx: Ctx): Response { |
| 441 | 415 |
); |
| 442 | 416 |
return shell( |
| 443 | 417 |
ctx, |
| 444 | |
card( |
| 445 | |
html`<p> |
| 418 |
html`<section class="settings-block" id="retire"> |
| 419 |
<h2>Remove ${username}</h2> |
| 420 |
<p> |
| 446 | 421 |
${orgs.length === 0 && own.length === 0 |
| 447 | 422 |
? html`Removing <strong>${username}</strong> removes only the account.` |
| 448 | 423 |
: html`Removing <strong>${username}</strong> also removes |
|
|
@@ -460,36 +435,24 @@ export function retirePage(ctx: Ctx): Response { |
| 460 | 435 |
${orgs.length === 0 && own.length === 0 |
| 461 | 436 |
? html`<p class="muted">They own no organization alone.</p>` |
| 462 | 437 |
: null} |
| 463 | |
<ul class="rows"> |
| 438 |
<ul class="list setting-list"> |
| 464 | 439 |
${own.length === 0 |
| 465 | 440 |
? null |
| 466 | |
: html`<li class="row"> |
| 467 | |
<div class="row-main"> |
| 468 | |
<strong>${username}</strong> |
| 469 | |
<div class="row-meta">${repoList(own)}</div> |
| 470 | |
</div> |
| 471 | |
</li>`} |
| 472 | |
${orgs.map( |
| 473 | |
(o) => html`<li class="row"> |
| 474 | |
<div class="row-main"> |
| 475 | |
<strong>${o.org}</strong> |
| 476 | |
<div class="row-meta"> |
| 477 | |
${o.repos.length === 0 ? "no repositories" : repoList(o.repos)} |
| 478 | |
</div> |
| 479 | |
<div class="row-meta"> |
| 480 | |
teams: ${o.teams.length === 0 ? "none" : o.teams.join(", ")} |
| 481 | |
</div> |
| 482 | |
</div> |
| 483 | |
</li>`, |
| 441 |
: row({ title: username, meta: html`${repoList(own)}` })} |
| 442 |
${orgs.map((o) => |
| 443 |
row({ |
| 444 |
title: o.org, |
| 445 |
meta: html`${o.repos.length === 0 ? "no repositories" : repoList(o.repos)} |
| 446 |
· teams: ${o.teams.length === 0 ? "none" : o.teams.join(", ")}`, |
| 447 |
}), |
| 484 | 448 |
)} |
| 485 | 449 |
</ul> |
| 486 | 450 |
<form method="post" action="${at}/retire" class="form-actions"> |
| 487 | 451 |
<input type="hidden" name="confirm" value="1" /> |
| 488 | 452 |
${btn("Remove everything", { type: "submit", size: "sm", variant: "danger" })} |
| 489 | 453 |
${btn("Keep them", { href: "/settings/accounts", size: "sm", variant: "ghost" })} |
| 490 | |
</form>`, |
| 491 | |
{ tone: "attn" }, |
| 492 | |
), |
| 454 |
</form> |
| 455 |
</section>`, |
| 493 | 456 |
); |
| 494 | 457 |
} |
| 495 | 458 |
|
|
|
@@ -519,28 +482,26 @@ export async function retireAct(ctx: Ctx): Promise<Response> { |
| 519 | 482 |
if (failed !== null) |
| 520 | 483 |
return shell( |
| 521 | 484 |
ctx, |
| 522 | |
card( |
| 523 | |
html`<p> |
| 524 | |
<strong>${failed}</strong> would not go, so the removal stopped |
| 525 | |
there. ${removed.length === 0 ? "Nothing was removed yet." : "Removed so far:"} |
| 485 |
html`<section class="settings-block"> |
| 486 |
<h2>Removal stopped</h2> |
| 487 |
<p> |
| 488 |
<strong>${failed}</strong> would not go. ${removed.length === 0 ? "Nothing was removed yet." : "Removed so far:"} |
| 526 | 489 |
Trying again finishes the job. |
| 527 | 490 |
</p> |
| 528 | 491 |
${removed.length === 0 ? null : list} |
| 529 | 492 |
<form method="post" action="/settings/accounts/${encodeURIComponent(username)}/retire" class="form-actions"> |
| 530 | 493 |
<input type="hidden" name="confirm" value="1" /> |
| 531 | 494 |
${btn("Try again", { type: "submit", size: "sm", variant: "danger" })} ${back} |
| 532 | |
</form>`, |
| 533 | |
{ tone: "danger" }, |
| 534 | |
), |
| 495 |
</form> |
| 496 |
</section>`, |
| 535 | 497 |
500, |
| 536 | 498 |
); |
| 537 | 499 |
return shell( |
| 538 | 500 |
ctx, |
| 539 | |
card( |
| 540 | |
html`<p> |
| 541 | |
${removed.length === 0 ? `${username} was already removed.` : "Removed:"} |
| 542 | |
</p> |
| 543 | |
${removed.length === 0 ? null : list} ${back}`, |
| 544 | |
), |
| 501 |
html`<section class="settings-block"> |
| 502 |
<h2>${removed.length === 0 ? `${username} was already removed.` : "Removed"}</h2> |
| 503 |
${removed.length === 0 ? null : list} |
| 504 |
<p>${back}</p> |
| 505 |
</section>`, |
| 545 | 506 |
); |
| 546 | 507 |
} |