Changes
11 changed files (+652/-3)
-
-
@@ -5,7 +5,11 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons";import { Callout, Flex, Text } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface ErrorCalloutProps { export interface ErrorCalloutProps extends Pick< Callout.RootProps, "variant" | "size" | "highContrast" | "style" | "role" > { className?: string | undefined; severity?: "danger" | "warning";
-
@@ -23,9 +27,14 @@ severity = "danger",title, children, actions, ...rest }) => { return ( <Callout.Root className={className} color={severity === "danger" ? "red" : "amber"}> <Callout.Root {...rest} className={className} color={severity === "danger" ? "red" : "amber"} > <Callout.Icon> <ExclamationTriangleIcon /> </Callout.Icon>
-
-
-
@@ -14,6 +14,7 @@ import * as NonErrorThrownError from "./errors/NonErrorThrownError.tsx";import * as NotFoundError from "./errors/NotFoundError.tsx"; import * as NoStorageSpaceError from "./errors/NoStorageSpaceError.tsx"; import * as MissingFieldError from "./errors/MissingFieldError.tsx"; import * as UserInputError from "./errors/UserInputError.tsx"; export interface ManagedProps extends ErrorCalloutProps { error: unknown;
-
@@ -46,6 +47,10 @@ }if (IllegalMessageError.is(error)) { return <IllegalMessageError.Callout error={error} {...rest} />; } if (UserInputError.is(error)) { return <UserInputError.Callout error={error} {...rest} />; } if (GenericError.is(error)) {
-
-
-
@@ -0,0 +1,20 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { UserInputError } from "../../../errors/UserInputError.ts"; import { Callout } from "./UserInputError.tsx"; export default { component: Callout, args: { title: "エラー", error: new UserInputError("Foo Bar"), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {};
-
-
-
@@ -0,0 +1,24 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type FC } from "react"; import { UserInputError } from "../../../errors/UserInputError.ts"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is UserInputError { return x instanceof UserInputError; } export interface CalloutProps extends ErrorCalloutProps { error: UserInputError; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <ErrorCallout {...rest} actions={actions}> {children || error.message || "処理できない入力内容です"} </ErrorCallout> ); };
-
-
-
@@ -0,0 +1,12 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only /** * ユーザの入力に対するエラー。 * そのまま表示するもの。 */ export class UserInputError extends Error { constructor(message: string) { super(message); } }
-
-
-
@@ -89,6 +89,57 @@ options,); } export interface CreateUserOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.createUser.output>["result"], { case: "ok" | undefined } >; } export function CreateUser({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: CreateUserOptions = {}) { return mock( WorkspaceService.method.createUser, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: { value: "wu-" + crypto.randomUUID(), }, name: req.name.trim(), displayName: req.displayName.trim() || req.name.trim(), isAdmin: req.isAdmin, loginMethod: { passwordConfigured: true, }, permissions: req.permissions, }, }, }; }, options, ); } export interface LoginOptions extends MockOptions { failureRate?: number;
-
-
-
@@ -22,6 +22,7 @@ import * as NavigationMenu from "../components/NavigationMenu.ts";import { URLContext } from "../contexts/Router.tsx"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import * as userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx"; import css from "./LoggedInLayout.module.css";
-
@@ -128,6 +129,13 @@ <a href={users.createHref()}><users.Title /> </a> </NavigationMenu.Item> {userNew.hasAccess(user) && ( <NavigationMenu.Item current={userNew.pattern.test(url)}> <a href={userNew.createHref()}> <userNew.Title /> </a> </NavigationMenu.Item> )} </NavigationMenu.Group> </NavigationMenu.Root> </ScrollArea>
-
-
-
@@ -6,6 +6,7 @@import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { CreateUser, Get, GetLoginUser, Login,
-
@@ -18,7 +19,7 @@ component: Page,parameters: { layout: "fullscreen", }, decorators: [withMockedBackend([GetLoginUser(), Login(), Get()])], decorators: [withMockedBackend([CreateUser(), GetLoginUser(), Login(), Get()])], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>;
-
-
-
@@ -10,6 +10,7 @@import * as Empty from "../components/Empty.ts"; import { Select } from "../contexts/Router.tsx"; import * as userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx"; import * as root from "./root/page.tsx"; import { LoggedInLayout } from "./LoggedInLayout.tsx";
-
@@ -24,6 +25,10 @@return ( <Select routes={[ { pattern: userNew.pattern, children: <userNew.Page loginUser={loginUser} />, }, { pattern: users.pattern, children: <users.Page loginUser={loginUser} />,
-
-
-
@@ -0,0 +1,72 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { userEvent } from "@storybook/test"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, CreateUser, } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, decorators: [withInmemoryRouter({ initialURL: "/user/new" })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([CreateUser()])], async play({ canvas }) { await userEvent.type(canvas.getByLabelText("ユーザ名"), "carol"); await userEvent.type(canvas.getByLabelText("表示名"), "Carol Curry"); await userEvent.type(canvas.getByLabelText("ログインパスワード"), "carol's password"); await userEvent.click(canvas.getByLabelText("他ユーザ情報閲覧")); await userEvent.click(canvas.getByRole("button", { name: "作成" })); }, }; export const LessPermission: Story = { decorators: [withMockedBackend([CreateUser()])], args: { loginUser: { ...alice, permissions: { ...alice.permissions!, canUpdateOtherRegularUserLoginMethod: false, canDeleteRegularUser: false, canUpdateOtherRegularUserProfile: false, canUpdateWorkspace: false, }, }, }, }; export const NoPermission: Story = { args: { loginUser: bob, }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([CreateUser({ delayMs: 2_000 })])], play: Success.play, }; export const SystemError: Story = { decorators: [withMockedBackend([CreateUser({ failureRate: 1 })])], play: Success.play, };
-
-
-
@@ -0,0 +1,442 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { Button, Box, Container, Flex, Heading, Switch, TextField, } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type CreateUserRequestSchema } from "@yamori/proto/yamori/workspace/v2/create_user_request_pb.js"; 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, use } from "react"; import { Controller, useForm } from "react-hook-form"; import * as Empty from "../../../components/Empty.ts"; import * as FormField from "../../../components/FormField.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../../contexts/ConnectTransport.tsx"; import { NavigationContext } from "../../../contexts/Router.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { UserInputError } from "../../../errors/UserInputError.ts"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; export const Title: FC = () => "ユーザ作成"; export function hasAccess(loginUser: User): boolean { return !!loginUser.permissions?.canAddUser; } export const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>作成権限がありません</Empty.Title> <Empty.Description> ユーザの作成権限がないためページを表示できません。 </Empty.Description> </Empty.Root> ); }; interface BodyProps { loginUser: User; onCreated?(): void; } const Body: FC<BodyProps> = ({ loginUser, onCreated }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const creation = useMutation({ async mutationFn(req: MessageInitShape<typeof CreateUserRequestSchema>) { const resp = await client.createUser(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "duplicatedName") { throw new UserInputError("ユーザ名が同一の別ユーザが既に存在します"); } if (resp.result.case === "passwordLessThanBytes") { throw new UserInputError( `${resp.result.value}文字以上のパスワードを入力してください`, ); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onCreated?.(); toast.open({ severity: "success", title: `ユーザ「${user.displayName}」を作成しました`, dismissible: true, type: "foreground", }); }, }); const form = useForm<{ name: string; displayName: string; loginPassword: string; canAddUser: boolean; canDeleteRegularUser: boolean; canReadOtherUserProfile: boolean; canUpdateOtherRegularUserProfile: boolean; canUpdateSelfProfile: boolean; canUpdateOtherRegularUserLoginMethod: boolean; canUpdateWorkspace: boolean; }>({ defaultValues: { name: "", displayName: "", loginPassword: "", canAddUser: false, canDeleteRegularUser: false, canReadOtherUserProfile: false, canUpdateOtherRegularUserProfile: false, canUpdateSelfProfile: loginUser.permissions?.canUpdateSelfProfile ?? false, canUpdateOtherRegularUserLoginMethod: false, canUpdateWorkspace: false, }, mode: "onBlur", }); return ( <Flex direction="column" mt="5" gap="5"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit(({ name, displayName, loginPassword, ...rest }) => { creation.mutate({ name, displayName, password: loginPassword, permissions: rest, }); })} > {creation.error ? ( <Box position="sticky" top="0" pt="2"> <ManagedErrorCallout style={{ backdropFilter: "blur(2em)" }} role="alert" error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="作成に失敗しました" /> </Box> ) : ( <div /> )} <Heading as="h2">基本情報</Heading> <FormField.Root> <FormField.Label htmlFor="c_name">ユーザ名</FormField.Label> <TextField.Root id="c_name" disabled={creation.isPending} placeholder="hanako_nihon" color={form.formState.errors.name ? "red" : undefined} aria-invalid={!!form.formState.errors.name} autoComplete="username" {...form.register("name", { required: "必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.name?.message}> ログイン時に利用するユーザ名です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_displayName">表示名</FormField.Label> <TextField.Root id="c_displayName" disabled={creation.isPending} placeholder="日本 花子" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> 他のユーザも閲覧可能な画面上の表示名です。 未指定の場合はユーザ名が設定されます。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_loginPassword"> ログインパスワード </FormField.Label> <TextField.Root id="c_loginPassword" disabled={creation.isPending} color={form.formState.errors.loginPassword ? "red" : undefined} aria-invalid={!!form.formState.errors.loginPassword} type="password" autoComplete="do_not_complete_idiot" {...form.register("loginPassword", { required: "必須です", minLength: { value: 8, message: "8文字以上を入力してください", }, })} /> <FormField.Description error={form.formState.errors.loginPassword?.message}> 新たに作成されるユーザが利用するログインパスワードです。 </FormField.Description> </FormField.Root> <Heading as="h2">権限</Heading> {loginUser.permissions?.canUpdateSelfProfile && ( <FormField.Root> <FormField.Label htmlFor="c_canUpdateSelfProfile"> アカウント情報編集 </FormField.Label> <Controller control={form.control} name="canUpdateSelfProfile" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateSelfProfile" disabled={creation.isPending} checked={value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 作成されたユーザが自身の表示名を変更するための権限です。 </FormField.Description> </FormField.Root> )} {loginUser.permissions?.canAddUser && ( <FormField.Root> <FormField.Label htmlFor="c_canAddUser">ユーザ作成</FormField.Label> <Controller control={form.control} name="canAddUser" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canAddUser" disabled={creation.isPending} checked={value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> システムに新しいユーザを作成・登録する権限です。 このユーザに与えられた権限と同じかそれ未満のユーザのみ作成・登録ができます。 </FormField.Description> </FormField.Root> )} {loginUser.permissions?.canDeleteRegularUser && ( <FormField.Root> <FormField.Label htmlFor="c_canDeleteRegularUser"> ユーザ削除 </FormField.Label> <Controller control={form.control} name="canDeleteRegularUser" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canDeleteRegularUser" disabled={creation.isPending} checked={value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> ユーザを削除する権限です。 管理者の削除は管理者のみ行えます。 </FormField.Description> </FormField.Root> )} {loginUser.permissions?.canReadOtherUserProfile && ( <FormField.Root> <FormField.Label htmlFor="c_canReadOtherUserProfile"> 他ユーザ情報閲覧 </FormField.Label> <Controller control={form.control} name="canReadOtherUserProfile" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canReadOtherUserProfile" disabled={creation.isPending} checked={value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 他のユーザの基本情報を閲覧する権限です。 ユーザ一覧の表示に必要となります。 </FormField.Description> </FormField.Root> )} {loginUser.permissions?.canUpdateOtherRegularUserProfile && ( <FormField.Root> <FormField.Label htmlFor="c_canUpdateOtherRegularUserProfile"> 他ユーザ情報編集 </FormField.Label> <Controller control={form.control} name="canUpdateOtherRegularUserProfile" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateOtherRegularUserProfile" disabled={creation.isPending} checked={value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 他のユーザの基本情報を編集する権限です。 管理者に対する変更は管理者のみ行えます。 </FormField.Description> </FormField.Root> )} {loginUser.permissions?.canUpdateOtherRegularUserLoginMethod && ( <FormField.Root> <FormField.Label htmlFor="c_canUpdateOtherRegularUserLoginMethod"> 他ユーザログイン設定変更 </FormField.Label> <Controller control={form.control} name="canUpdateOtherRegularUserLoginMethod" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateOtherRegularUserLoginMethod" disabled={creation.isPending} checked={value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 他のユーザのパスワードを変更する権限です。 管理者に対する変更は管理者のみ行えます。 </FormField.Description> </FormField.Root> )} {loginUser.permissions?.canUpdateWorkspace && ( <FormField.Root> <FormField.Label htmlFor="c_canUpdateWorkspace"> ワークスペース設定変更 </FormField.Label> <Controller control={form.control} name="canUpdateWorkspace" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateWorkspace" disabled={creation.isPending} checked={value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> カスタム休暇の登録やカスタム属性の登録、年次有給休暇の払い出しテーブルの変更といった ワークスペース全体の変更を行う権限です。 </FormField.Description> </FormField.Root> )} <Button loading={creation.isPending}>作成</Button> </form> </Flex> </Flex> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser) ? ( <Body loginUser={loginUser} onCreated={() => { navigation.push("/users"); }} /> ) : ( <NoAccess /> )} </Container> </LoggedInLayout> ); }; export function createHref(): string { return "/user/new"; } export const pattern = new URLPattern({ pathname: "/user/new", });
-