From a97aaada8464d11920a0edd4148d77ce53740d9b Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 10 Feb 2025 15:24:49 +0100 Subject: [PATCH 1/4] fix(UI/UX): add a validation step before leaving scope --- .../@[scope]/(_islands)/ScopeInviteForm.tsx | 4 +- .../@[scope]/(_islands)/ScopeMemberLeave.tsx | 85 +++++ frontend/routes/@[scope]/~/members.tsx | 299 ------------------ 3 files changed, 88 insertions(+), 300 deletions(-) create mode 100644 frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx diff --git a/frontend/routes/@[scope]/(_islands)/ScopeInviteForm.tsx b/frontend/routes/@[scope]/(_islands)/ScopeInviteForm.tsx index ed5c07c5..d29c49dd 100644 --- a/frontend/routes/@[scope]/(_islands)/ScopeInviteForm.tsx +++ b/frontend/routes/@[scope]/(_islands)/ScopeInviteForm.tsx @@ -4,6 +4,7 @@ import { useCallback, useRef } from "preact/hooks"; import { JSX } from "preact/jsx-runtime"; import { ScopeInvite } from "../../../utils/api_types.ts"; import { api, path } from "../../../utils/api.ts"; +import { TbUsersPlus } from "tb-icons"; interface ScopeInviteFormProps { scope: string; @@ -53,7 +54,7 @@ export function ScopeInviteForm(props: ScopeInviteFormProps) { class="contents" onSubmit={onSubmit} > -
+
+ {(isLastAdmin || isInvalidInput.value) && ( +
+ Warning +

+ {isLastAdmin && + "You are the last admin in this scope. You must promote another member to admin before leaving."} + {isInvalidInput.value && + "The scope name you entered does not match the scope name."} +

+
+ )} +
+ { + scopeInput.value = (e.target as HTMLInputElement).value; + }} + placeholder="Scope name" + disabled={isLastAdmin} + title={isLastAdmin + ? "This is the last admin in this scope. Promote another member to admin before demoting this one." + : undefined} + /> + +
+ + ); +} diff --git a/frontend/routes/@[scope]/~/members.tsx b/frontend/routes/@[scope]/~/members.tsx index 62baaba7..9ba9d416 100644 --- a/frontend/routes/@[scope]/~/members.tsx +++ b/frontend/routes/@[scope]/~/members.tsx @@ -1,200 +1,3 @@ -// Copyright 2024 the JSR authors. All rights reserved. MIT license. -import { HttpError } from "fresh"; -import { define } from "../../../util.ts"; -import { ScopeHeader } from "../(_components)/ScopeHeader.tsx"; -import { ScopeNav } from "../(_components)/ScopeNav.tsx"; -import { ScopePendingInvite } from "../(_components)/ScopePendingInvite.tsx"; -import { ScopeInviteForm } from "../(_islands)/ScopeInviteForm.tsx"; -import { ScopeMemberRole } from "../(_islands)/ScopeMemberRole.tsx"; -import { Table, TableData, TableRow } from "../../../components/Table.tsx"; -import { CopyButton } from "../../../islands/CopyButton.tsx"; -import { path } from "../../../utils/api.ts"; -import { - FullUser, - ScopeInvite, - ScopeMember, -} from "../../../utils/api_types.ts"; -import { scopeData } from "../../../utils/data.ts"; -import TbTrash from "tb-icons/TbTrash"; -import { scopeIAM } from "../../../utils/iam.ts"; -import { ScopeIAM } from "../../../utils/iam.ts"; - -export default define.page(function ScopeMembersPage( - { params, data, state, url }, -) { - const iam = scopeIAM(state, data.scopeMember); - - const hasOneAdmin = data.members.filter((member) => - member.isAdmin - ).length === 1; - - const isLastAdmin = (data.scopeMember?.isAdmin || false) && hasOneAdmin; - - const inviteUrl = url.href; - - return ( -
- - - - i.targetUser.id === state.user?.id - )} - scope={params.scope} - /> - - {data.members.map((member) => ( - - ))} - {data.invites.map((invite) => ( - - ))} -
- {iam.canAdmin && } - {data.scopeMember && ( - - )} -
- ); -}); - -interface MemberItemProps { - isLastAdmin: boolean; - member: ScopeMember; - iam: ScopeIAM; -} - -export function MemberItem(props: MemberItemProps) { - const { member, iam } = props; - return ( - - - - {member.user.name} - - - - {iam.canAdmin - ? ( - - ) - : member.isAdmin - ? "Admin" - : "Member"} - - {iam.canAdmin && ( - -
-
- - -
-
-
- )} -
- ); -} - -interface InviteItemProps { - invite: ScopeInvite; - inviteUrl: string; - iam: ScopeIAM; -} - -export function InviteItem(props: InviteItemProps) { - const { invite, iam } = props; - return ( - - - - {invite.targetUser.name} - - - - Invited - - {iam.canAdmin && ( - -
- -
- - -
-
-
- )} -
- ); -} - -function MemberInvite({ scope }: { scope: string }) { - return ( -
-

Invite member

-

- Inviting users to this scope grants them access to publish all packages - in this scope and create new packages. They will not be able to manage - members unless they are granted admin status. -

- -
- ); -} - function MemberLeave( props: { userId: string; isAdmin: boolean; isLastAdmin: boolean }, ) { @@ -231,105 +34,3 @@ function MemberLeave( ); } - -export const handler = define.handlers({ - async GET(ctx) { - let [user, data, membersResp, invitesResp] = await Promise.all([ - ctx.state.userPromise, - scopeData(ctx.state, ctx.params.scope), - ctx.state.api.get( - path`/scopes/${ctx.params.scope}/members`, - ), - ctx.state.api.hasToken() - ? ctx.state.api.get( - path`/scopes/${ctx.params.scope}/invites`, - ) - : Promise.resolve(null), - ]); - if (user instanceof Response) return user; - if (data === null) throw new HttpError(404, "The scope was not found."); - if (!membersResp.ok) { - if (membersResp.code === "scopeNotFound") { - throw new HttpError(404, "The scope was not found."); - } - throw membersResp; // graceful handle errors - } - if (invitesResp && !invitesResp.ok) { - if ( - invitesResp.code === "actorNotScopeMember" || - invitesResp.code === "actorNotScopeAdmin" - ) { - invitesResp = null; - } else { - if (invitesResp.code === "scopeNotFound") { - throw new HttpError(404, "The scope was not found."); - } - throw invitesResp; // graceful handle errors - } - } - - const scopeMember = membersResp.data.find((member) => - member.user.id === (user as FullUser | null)?.id - ) ?? null; - - ctx.state.meta = { - title: `Members - @${ctx.params.scope} - JSR`, - description: `List of members of the @${ctx.params.scope} scope on JSR.`, - }; - return { - data: { - scope: data.scope, - scopeMember: scopeMember, - members: membersResp.data, - invites: invitesResp?.data ?? [], - }, - }; - }, - async POST(ctx) { - const req = ctx.req; - const scope = ctx.params.scope; - const form = await req.formData(); - const action = form.get("action"); - if (action === "deleteInvite") { - const userId = String(form.get("userId")); - const res = await ctx.state.api.delete( - path`/scopes/${scope}/invites/${userId}`, - ); - if (!res.ok) { - if (res.code === "scopeNotFound") { - throw new HttpError(404, "The scope was not found."); - } - throw res; // graceful handle errors - } - } else if (action === "deleteMember") { - const userId = String(form.get("userId")); - const res = await ctx.state.api.delete( - path`/scopes/${scope}/members/${userId}`, - ); - if (!res.ok) { - if (res.code === "scopeNotFound") { - throw new HttpError(404, "The scope was not found."); - } - throw res; // graceful handle errors - } - } else if (action === "invite") { - const githubLogin = String(form.get("githubLogin")); - const res = await ctx.state.api.post( - path`/scopes/${scope}/members`, - { githubLogin }, - ); - if (!res.ok) { - if (res.code === "scopeNotFound") { - throw new HttpError(404, "The scope was not found."); - } - throw res; // graceful handle errors - } - } else { - throw new Error("Invalid action"); - } - return new Response(null, { - status: 303, - headers: { Location: `/@${scope}/~/members` }, - }); - }, -}); From ecfe51ef3bd0bfc595c4bd4b43dd6877c59e67e0 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 17 Apr 2025 23:07:36 +0200 Subject: [PATCH 2/4] fix: remove layout shift --- .../@[scope]/(_islands)/ScopeMemberLeave.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx b/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx index 923ba1a6..c96799cf 100644 --- a/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx +++ b/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx @@ -44,17 +44,6 @@ export function ScopeMemberLeave({ scope{isAdmin && " or manage members"}.

- {(isLastAdmin || isInvalidInput.value) && ( -
- Warning -

- {isLastAdmin && - "You are the last admin in this scope. You must promote another member to admin before leaving."} - {isInvalidInput.value && - "The scope name you entered does not match the scope name."} -

-
- )}
+ {(isLastAdmin || isInvalidInput.value) && ( +
+ Warning +

+ {isLastAdmin && + "You are the last admin in this scope. You must promote another member to admin before leaving."} + {isInvalidInput.value && + "The scope name you entered does not match the scope name."} +

+
+ )} ); } From a1524041903c2767ef299168bfa52361d4b27fbf Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 26 Apr 2025 11:53:11 +0200 Subject: [PATCH 3/4] fix rebase + dark theme --- .../@[scope]/(_islands)/ScopeMemberLeave.tsx | 2 +- frontend/routes/@[scope]/~/members.tsx | 324 ++++++++++++++++-- 2 files changed, 295 insertions(+), 31 deletions(-) diff --git a/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx b/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx index c96799cf..5756a9d4 100644 --- a/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx +++ b/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx @@ -70,7 +70,7 @@ export function ScopeMemberLeave({
{(isLastAdmin || isInvalidInput.value) && ( -
+
Warning

{isLastAdmin && diff --git a/frontend/routes/@[scope]/~/members.tsx b/frontend/routes/@[scope]/~/members.tsx index 9ba9d416..dc98ad46 100644 --- a/frontend/routes/@[scope]/~/members.tsx +++ b/frontend/routes/@[scope]/~/members.tsx @@ -1,36 +1,300 @@ -function MemberLeave( - props: { userId: string; isAdmin: boolean; isLastAdmin: boolean }, +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import { HttpError } from "fresh"; +import { define } from "../../../util.ts"; +import { ScopeHeader } from "../(_components)/ScopeHeader.tsx"; +import { ScopeNav } from "../(_components)/ScopeNav.tsx"; +import { ScopePendingInvite } from "../(_components)/ScopePendingInvite.tsx"; +import { ScopeInviteForm } from "../(_islands)/ScopeInviteForm.tsx"; +import { ScopeMemberRole } from "../(_islands)/ScopeMemberRole.tsx"; +import { Table, TableData, TableRow } from "../../../components/Table.tsx"; +import { CopyButton } from "../../../islands/CopyButton.tsx"; +import { path } from "../../../utils/api.ts"; +import { + FullUser, + ScopeInvite, + ScopeMember, +} from "../../../utils/api_types.ts"; +import { scopeData } from "../../../utils/data.ts"; +import TbTrash from "tb-icons/TbTrash"; +import { scopeIAM } from "../../../utils/iam.ts"; +import { ScopeIAM } from "../../../utils/iam.ts"; +import { ScopeMemberLeave } from "../(_islands)/ScopeMemberLeave.tsx"; + +export default define.page(function ScopeMembersPage( + { params, data, state, url }, ) { + const iam = scopeIAM(state, data.scopeMember); + + const hasOneAdmin = data.members.filter((member) => + member.isAdmin + ).length === 1; + + const isLastAdmin = (data.scopeMember?.isAdmin || false) && hasOneAdmin; + + const inviteUrl = url.href; + return ( -

-

Leave scope

+
+ + + + i.targetUser.id === state.user?.id + )} + scope={params.scope} + /> + + {data.members.map((member) => ( + + ))} + {data.invites.map((invite) => ( + + ))} +
+ {iam.canAdmin && } + {data.scopeMember && ( + + )} +
+ ); +}); + +interface MemberItemProps { + isLastAdmin: boolean; + member: ScopeMember; + iam: ScopeIAM; +} + +export function MemberItem(props: MemberItemProps) { + const { member, iam } = props; + return ( + + + + {member.user.name} + + + + {iam.canAdmin + ? ( + + ) + : member.isAdmin + ? "Admin" + : "Member"} + + {iam.canAdmin && ( + +
+ + + + +
+
+ )} +
+ ); +} + +interface InviteItemProps { + invite: ScopeInvite; + inviteUrl: string; + iam: ScopeIAM; +} + +export function InviteItem(props: InviteItemProps) { + const { invite, iam } = props; + return ( + + + + {invite.targetUser.name} + + + + Invited + + {iam.canAdmin && ( + +
+ +
+ + +
+
+
+ )} +
+ ); +} + +function MemberInvite({ scope }: { scope: string }) { + return ( +
+

Invite member

- Leaving this scope will revoke your access to all packages in this - scope. You will no longer be able to publish packages to this - scope{props.isAdmin && " or manage members"}. + Inviting users to this scope grants them access to publish all packages + in this scope and create new packages. They will not be able to manage + members unless they are granted admin status.

- - {props.isLastAdmin && ( -
- Warning -

- You are the last admin in this scope. You must promote another - member to admin before leaving. -

-
- )} - - + +
); } + +export const handler = define.handlers({ + async GET(ctx) { + let [user, data, membersResp, invitesResp] = await Promise.all([ + ctx.state.userPromise, + scopeData(ctx.state, ctx.params.scope), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/members`, + ), + ctx.state.api.hasToken() + ? ctx.state.api.get( + path`/scopes/${ctx.params.scope}/invites`, + ) + : Promise.resolve(null), + ]); + if (user instanceof Response) return user; + if (data === null) throw new HttpError(404, "The scope was not found."); + if (!membersResp.ok) { + if (membersResp.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw membersResp; // graceful handle errors + } + if (invitesResp && !invitesResp.ok) { + if ( + invitesResp.code === "actorNotScopeMember" || + invitesResp.code === "actorNotScopeAdmin" + ) { + invitesResp = null; + } else { + if (invitesResp.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw invitesResp; // graceful handle errors + } + } + + const scopeMember = membersResp.data.find((member) => + member.user.id === (user as FullUser | null)?.id + ) ?? null; + + ctx.state.meta = { + title: `Members - @${ctx.params.scope} - JSR`, + description: `List of members of the @${ctx.params.scope} scope on JSR.`, + }; + return { + data: { + scope: data.scope, + scopeMember: scopeMember, + members: membersResp.data, + invites: invitesResp?.data ?? [], + }, + }; + }, + async POST(ctx) { + const req = ctx.req; + const scope = ctx.params.scope; + const form = await req.formData(); + const action = form.get("action"); + if (action === "deleteInvite") { + const userId = String(form.get("userId")); + const res = await ctx.state.api.delete( + path`/scopes/${scope}/invites/${userId}`, + ); + if (!res.ok) { + if (res.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw res; // graceful handle errors + } + } else if (action === "deleteMember") { + const userId = String(form.get("userId")); + const res = await ctx.state.api.delete( + path`/scopes/${scope}/members/${userId}`, + ); + if (!res.ok) { + if (res.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw res; // graceful handle errors + } + } else if (action === "invite") { + const githubLogin = String(form.get("githubLogin")); + const res = await ctx.state.api.post( + path`/scopes/${scope}/members`, + { githubLogin }, + ); + if (!res.ok) { + if (res.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw res; // graceful handle errors + } + } else { + throw new Error("Invalid action"); + } + return new Response(null, { + status: 303, + headers: { Location: `/@${scope}/~/members` }, + }); + }, +}); From 1483c3264780d74f96ce2d9a7be810271194b602 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 4 May 2025 23:38:31 +0200 Subject: [PATCH 4/4] fix: dark theme --- frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx b/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx index 5756a9d4..a6cf27fa 100644 --- a/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx +++ b/frontend/routes/@[scope]/(_islands)/ScopeMemberLeave.tsx @@ -38,7 +38,7 @@ export function ScopeMemberLeave({ class="max-w-3xl border-t border-jsr-cyan-950/10 pt-8 mt-12" >

Leave scope

-

+

Leaving this scope will revoke your access to all packages in this scope. You will no longer be able to publish packages to this scope{isAdmin && " or manage members"}.