Skip to content

Commit d18bccb

Browse files
fix(UI/UX): add a validation step before leaving scope
1 parent 4d0fe9d commit d18bccb

File tree

3 files changed

+91
-39
lines changed

3 files changed

+91
-39
lines changed

frontend/routes/@[scope]/(_islands)/ScopeInviteForm.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useRef } from "preact/hooks";
44
import { JSX } from "preact/jsx-runtime";
55
import { ScopeInvite } from "../../../utils/api_types.ts";
66
import { api, path } from "../../../utils/api.ts";
7+
import { TbUsersPlus } from "tb-icons";
78

89
interface ScopeInviteFormProps {
910
scope: string;
@@ -53,7 +54,7 @@ export function ScopeInviteForm(props: ScopeInviteFormProps) {
5354
class="contents"
5455
onSubmit={onSubmit}
5556
>
56-
<div class="mt-4 flex gap-4">
57+
<div class="mt-4 flex gap-4 justify-between">
5758
<div class="flex">
5859
<select
5960
name="kind"
@@ -92,6 +93,7 @@ export function ScopeInviteForm(props: ScopeInviteFormProps) {
9293
disabled={submitting}
9394
>
9495
Invite
96+
<TbUsersPlus class="size-5 ml-2" />
9597
</button>
9698
</div>
9799
{error && <p class="text-red-600 mt-2">{error}</p>}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2024 the JSR authors. All rights reserved. MIT license.
2+
import { useSignal } from "@preact/signals";
3+
import { useEffect } from "preact/hooks";
4+
import { TbArrowRightFromArc } from "tb-icons";
5+
6+
export function ScopeMemberLeave({
7+
userId,
8+
isAdmin,
9+
isLastAdmin,
10+
scopeName = "",
11+
}: {
12+
userId: string;
13+
isAdmin: boolean;
14+
isLastAdmin: boolean;
15+
scopeName?: string;
16+
}) {
17+
const scopeInput = useSignal("");
18+
const isEmptyInput = useSignal(false);
19+
const isInvalidInput = useSignal(false);
20+
21+
useEffect(() => {
22+
const handler = setTimeout(() => {
23+
validate();
24+
}, 300);
25+
26+
return () => clearTimeout(handler);
27+
}, [scopeInput.value]);
28+
29+
const validate = () => {
30+
isEmptyInput.value = scopeInput.value.length === 0;
31+
isInvalidInput.value = scopeInput.value !== scopeName &&
32+
scopeInput.value.length > 0;
33+
};
34+
35+
return (
36+
<form
37+
method="POST"
38+
class="max-w-3xl border-t border-jsr-cyan-950/10 pt-8 mt-12"
39+
>
40+
<h2 class="text-lg font-semibold">Leave scope</h2>
41+
<p class="mt-2 text-jsr-gray-600">
42+
Leaving this scope will revoke your access to all packages in this
43+
scope. You will no longer be able to publish packages to this
44+
scope{isAdmin && " or manage members"}.
45+
</p>
46+
<input type="hidden" name="userId" value={userId} />
47+
{(isLastAdmin || isInvalidInput.value) && (
48+
<div class="mt-6 border rounded-md border-red-300 bg-red-50 p-6 text-red-600">
49+
<span class="font-bold text-xl">Warning</span>
50+
<p>
51+
{isLastAdmin &&
52+
"You are the last admin in this scope. You must promote another member to admin before leaving."}
53+
{isInvalidInput.value &&
54+
"The scope name you entered does not match the scope name."}
55+
</p>
56+
</div>
57+
)}
58+
<div class="mt-4 flex justify-between gap-4">
59+
<input
60+
type="text"
61+
class="inline-block w-full max-w-sm px-3 input-container text-sm input"
62+
value={scopeInput.value}
63+
onInput={(e) => {
64+
scopeInput.value = (e.target as HTMLInputElement).value;
65+
}}
66+
placeholder="Scope name"
67+
disabled={isLastAdmin}
68+
title={isLastAdmin
69+
? "This is the last admin in this scope. Promote another member to admin before demoting this one."
70+
: undefined}
71+
/>
72+
<button
73+
class="button-danger"
74+
type="submit"
75+
name="action"
76+
value="deleteMember"
77+
disabled={isLastAdmin || isInvalidInput.value || isEmptyInput.value}
78+
>
79+
Leave
80+
<TbArrowRightFromArc class="size-5 ml-2 rotate-180" />
81+
</button>
82+
</div>
83+
</form>
84+
);
85+
}

frontend/routes/@[scope]/~/members.tsx

+3-38
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { scopeData } from "../../../utils/data.ts";
1818
import TbTrash from "tb-icons/TbTrash";
1919
import { scopeIAM } from "../../../utils/iam.ts";
2020
import { ScopeIAM } from "../../../utils/iam.ts";
21+
import { ScopeMemberLeave } from "../(_islands)/ScopeMemberLeave.tsx";
2122

2223
export default define.page<typeof handler>(function ScopeMembersPage(
2324
{ params, data, state, url },
@@ -70,10 +71,11 @@ export default define.page<typeof handler>(function ScopeMembersPage(
7071
</Table>
7172
{iam.canAdmin && <MemberInvite scope={data.scope.scope} />}
7273
{data.scopeMember && (
73-
<MemberLeave
74+
<ScopeMemberLeave
7475
userId={data.scopeMember.user.id}
7576
isAdmin={data.scopeMember.isAdmin}
7677
isLastAdmin={isLastAdmin}
78+
scopeName={data.scope.scope}
7779
/>
7880
)}
7981
</div>
@@ -195,43 +197,6 @@ function MemberInvite({ scope }: { scope: string }) {
195197
);
196198
}
197199

198-
function MemberLeave(
199-
props: { userId: string; isAdmin: boolean; isLastAdmin: boolean },
200-
) {
201-
return (
202-
<form
203-
method="POST"
204-
class="max-w-3xl border-t border-jsr-cyan-950/10 pt-8 mt-12"
205-
>
206-
<h2 class="text-lg font-semibold">Leave scope</h2>
207-
<p class="mt-2 text-jsr-gray-600">
208-
Leaving this scope will revoke your access to all packages in this
209-
scope. You will no longer be able to publish packages to this
210-
scope{props.isAdmin && " or manage members"}.
211-
</p>
212-
<input type="hidden" name="userId" value={props.userId} />
213-
{props.isLastAdmin && (
214-
<div class="mt-6 border-1 rounded-md border-red-300 bg-red-50 p-6 text-red-600">
215-
<span class="font-bold text-xl">Warning</span>
216-
<p>
217-
You are the last admin in this scope. You must promote another
218-
member to admin before leaving.
219-
</p>
220-
</div>
221-
)}
222-
<button
223-
class="button-danger mt-6"
224-
type="submit"
225-
name="action"
226-
value="deleteMember"
227-
disabled={props.isLastAdmin}
228-
>
229-
Leave
230-
</button>
231-
</form>
232-
);
233-
}
234-
235200
export const handler = define.handlers({
236201
async GET(ctx) {
237202
let [user, data, membersResp, invitesResp] = await Promise.all([

0 commit comments

Comments
 (0)