Changes
8 changed files (+454/-8)
-
-
@@ -7,6 +7,7 @@ import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx";import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { CreateUser, DeleteUser, Get, GetLoginUser, Login,
-
@@ -23,6 +24,7 @@ },decorators: [ withMockedBackend([ CreateUser(), DeleteUser(), GetLoginUser(), Login(), Get(),
-
-
-
@@ -15,6 +15,7 @@ import * as customAttributes from "./custom-attributes/page.tsx";import * as customAttributesId from "./custom-attributes/:id/page.tsx"; import * as userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx"; import * as usersId from "./users/:id/page.tsx"; import * as root from "./root/page.tsx"; import { LoggedInLayout } from "./LoggedInLayout.tsx";
-
@@ -31,6 +32,10 @@ routes={[{ pattern: userNew.pattern, children: <userNew.Page loginUser={loginUser} />, }, { pattern: usersId.pattern, children: <usersId.Page loginUser={loginUser} />, }, { pattern: users.pattern,
-
-
-
@@ -0,0 +1,77 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, DeleteUser, } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, createHref } from "./Details.tsx"; const user = create(UserSchema, { id: { value: "cf-foo", }, name: "foo", displayName: "Foo", }); export default { component: Page, decorators: [withInmemoryRouter({ initialURL: createHref({ user }) })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, user, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([DeleteUser()])], }; export const NoDeletePermission: Story = { args: { loginUser: bob, }, decorators: [withMockedBackend([DeleteUser()])], }; export const RegularUserViewingAdmin: Story = { args: { user: create(UserSchema, { id: { value: "cf-foo", }, name: "foo", displayName: "Foo", isAdmin: true, }), loginUser: create(UserSchema, { ...bob, permissions: { ...bob.permissions!, canReadOtherUserProfile: true, canDeleteRegularUser: true, }, }), }, decorators: [withMockedBackend([DeleteUser()])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([DeleteUser({ delayMs: 2_000 })])], play: Success.play, };
-
-
-
@@ -0,0 +1,89 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { Button, Container, DataList, Flex, Heading } from "@radix-ui/themes"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC, use } from "react"; import { NavigationContext } from "../../../contexts/Router.tsx"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; import * as DeleteDialog from "../DeleteDialog.tsx"; import * as list from "../page.tsx"; const Title: FC = () => "ユーザ詳細"; export interface PageProps { user: User; loginUser: User; } export const Page: FC<PageProps> = ({ user, loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> <Flex direction="column" mt="5" gap="4"> <Heading as="h2" mt="3"> 基本情報 </Heading> <DataList.Root> <DataList.Item> <DataList.Label>ユーザ名</DataList.Label> <DataList.Value>{user.name}</DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>表示名</DataList.Label> <DataList.Value>{user.displayName}</DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>ユーザ種別</DataList.Label> <DataList.Value>{user.isAdmin ? "管理者" : "一般ユーザ"}</DataList.Value> </DataList.Item> </DataList.Root> <Heading as="h2" mt="3"> 操作 </Heading> <Flex wrap="wrap" gap="2" align="center"> <DeleteDialog.Root> <DeleteDialog.Trigger> <Button disabled={!DeleteDialog.isAvailable({ loginUser, user })} variant="soft" color="red" > 削除 </Button> </DeleteDialog.Trigger> <DeleteDialog.Content user={user} onDeleted={() => { navigation.push(list.createHref()); }} /> </DeleteDialog.Root> </Flex> </Flex> </Container> </LoggedInLayout> ); }; export interface CreateHrefInput { user: User; } export function createHref({ user }: CreateHrefInput): string { if (!user.id) { return "/users"; } return `/users/${user.id.value}`; } export const pattern = new URLPattern({ pathname: "/users/:user", });
-
-
-
@@ -0,0 +1,74 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Decorator, Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { On } from "../../../contexts/Router.tsx"; import { alice, bob, Get } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, pattern } from "./page.tsx"; function withRoute<Args>(): Decorator<Args> { return (Story) => ( <On pattern={pattern}> <Story /> </On> ); } export default { component: Page, parameters: { layout: "fullscreen", }, args: { loginUser: alice, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const PageNotFound: Story = { decorators: [ withRoute(), withMockedBackend([Get()]), withInmemoryRouter({ initialURL: "/users/wu-alice/invalid" }), ], }; export const NoPermission: Story = { args: { loginUser: bob, }, decorators: [ withRoute(), withMockedBackend([Get()]), withInmemoryRouter({ initialURL: "/users/wu-alice" }), ], }; export const NotFound: Story = { decorators: [ withRoute(), withMockedBackend([Get()]), withInmemoryRouter({ initialURL: "/users/wu-foo" }), ], }; export const SystemError: Story = { decorators: [ withRoute(), withMockedBackend([Get({ failureRate: 1 })]), withInmemoryRouter({ initialURL: "/users/wu-alice" }), ], }; export const SlowLoad: Story = { decorators: [ withRoute(), withMockedBackend([Get({ delayMs: 2_000 })]), withInmemoryRouter({ initialURL: "/users/wu-alice" }), ], };
-
-
-
@@ -0,0 +1,185 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Button, Code, Container, Flex, Spinner, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../../contexts/ConnectTransport.tsx"; import { Select, useURLPatternResult } from "../../../contexts/Router.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; import * as list from "../page.tsx"; import * as details from "./Details.tsx"; const Title: FC = () => "ユーザ詳細"; export function hasAccess(loginUser: User, userId: string): boolean { return ( loginUser.id?.value === userId || !!loginUser.permissions?.canReadOtherUserProfile ); } const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>閲覧権限がありません</Empty.Title> <Empty.Description> 対象のユーザ情報の閲覧権限がないためページを表示できません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> ); }; interface BodyProps { loginUser: User; userId: string; } const Body: FC<BodyProps> = ({ loginUser, userId }) => { const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const query = useQuery({ queryKey: [ WorkspaceService.typeName, WorkspaceService.method.get.name, import.meta.url, userId, ], async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") { // Tanstack Query が `undefined` をエラーシグナルとして濫用しているため回避が必要 // `undefined` を返すとエラー状態になる(デザインバグだが治る見込みはない) return resp.result.value.users.find((u) => u.id?.value === userId) ?? null; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, }); if (query.isError) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>ユーザ詳細の取得に失敗</Empty.Title> <Empty.Description> ユーザ詳細の取得中にエラーが発生しました。 </Empty.Description> <ManagedErrorCallout title="取得失敗" error={query.error} /> <Empty.Actions> <Button size="3" onClick={() => void query.refetch()}> 再取得 </Button> <Button asChild size="3" variant="outline"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } if (query.isLoading) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Flex position="absolute" inset="0" align="center" justify="center" gap="2"> <Spinner /> <Text size="2" color="gray"> ユーザ詳細を読込中... </Text> </Flex> </LoggedInLayout> ); } if (!query.data) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>該当するユーザは存在しません</Empty.Title> <Empty.Description> ID <Code>{userId}</Code> のユーザは登録されていません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } const user = query.data; return ( <Select routes={[ { pattern: details.pattern, children: <details.Page user={user} loginUser={loginUser} />, }, ]} fallback={ <LoggedInLayout title="404" user={loginUser}> <Empty.Root> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </LoggedInLayout> } /> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const { pathname } = useURLPatternResult(); const userId = pathname.groups.user || ""; if (!hasAccess(loginUser, userId)) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <NoAccess /> </LoggedInLayout> ); } return <Body loginUser={loginUser} userId={userId} />; }; export const pattern = new URLPattern({ pathname: "/users/:user(wu-[a-zA-Zd]+)/:frag*", });
-
-
-
@@ -5,7 +5,7 @@ import { type MessageInitShape } from "@bufbuild/protobuf";import { createClient } from "@connectrpc/connect"; import { AlertDialog, Button, Flex } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type UserSchema, type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type DeleteUserRequestSchema } from "@yamori/proto/yamori/workspace/v2/delete_user_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react";
-
@@ -13,6 +13,18 @@import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; export interface IsAvailableInput { loginUser: User; user: User; } export function isAvailable({ loginUser, user }: IsAvailableInput): boolean { return !!( loginUser.id?.value !== user.id?.value && (user.isAdmin ? loginUser.isAdmin : loginUser.permissions?.canDeleteRegularUser) ); } export const Root = AlertDialog.Root; export const Trigger = AlertDialog.Trigger;
-
-
-
@@ -27,6 +27,7 @@ import { IllegalMessageError } from "../../errors/IllegalMessageError.ts";import { LoggedInLayout } from "../LoggedInLayout.tsx"; import * as userDetails from "./:id/Details.tsx"; import * as DeleteDialog from "./DeleteDialog.tsx"; export const Title: FC = () => "ユーザ一覧";
-
@@ -137,12 +138,6 @@ <Flex direction="column" gap="4" role="list">{users.data.map((user, i) => { const isLoginUser = loginUser.id?.value === user.id?.value; const deletable = !isLoginUser && (user.isAdmin ? loginUser.isAdmin : !!loginUser.permissions?.canDeleteRegularUser); return ( <Fragment key={user.id?.value}> {i > 0 && <Separator size="4" />}
-
@@ -170,10 +165,17 @@ </Button></DropdownMenu.Trigger> </Flex> <DropdownMenu.Content> <DropdownMenu.Item asChild> <a href={userDetails.createHref({ user })}>詳細</a> </DropdownMenu.Item> <DeleteDialog.Trigger> <DropdownMenu.Item color="red" disabled={!deletable || users.isFetching || isDeleting} disabled={ !DeleteDialog.isAvailable({ loginUser, user }) || users.isFetching || isDeleting } > 削除 </DropdownMenu.Item>
-