Changes
3 changed files (+259/-0)
-
-
@@ -58,3 +58,53 @@ },options, ); } export interface LoginOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.login.output>["result"], { case: "ok" | undefined } >; } export function Login({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: LoginOptions = {}) { return mock( WorkspaceService.method.login, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: { value: "ws-" + crypto.randomUUID(), }, name: req.name.trim(), displayName: req.name.toUpperCase(), isAdmin: true, loginMethod: { passwordConfigured: true, }, }, }, }; }, options, ); }
-
-
-
@@ -0,0 +1,63 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor } from "@storybook/test"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import * as mock from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Login } from "./Login.tsx"; export default { component: Login, decorators: [withInmemoryRouter()], args: { onLogin: action("onLogin"), }, } satisfies Meta<typeof Login>; type Story = StoryObj<typeof Login>; export const Success: Story = { decorators: [withMockedBackend([mock.Login()])], }; export const Slow: Story = { decorators: [withMockedBackend([mock.Login({ delayMs: 1_000 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([mock.Login({ failureRate: 1 })])], async play({ canvas }) { await userEvent.type(canvas.getByRole("textbox", { name: "ユーザ名" }), "user"); await userEvent.type(canvas.getByLabelText("パスワード"), "password"); await userEvent.click(canvas.getByRole("button", { name: "ログイン" })); await waitFor(() => expect(canvas.getByText(/システムエラー/)).toBeInTheDocument()); }, }; export const AuthenticationError: Story = { decorators: [ withMockedBackend([ mock.Login({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), ]), ], async play({ canvas }) { await userEvent.type(canvas.getByRole("textbox", { name: "ユーザ名" }), "user"); await userEvent.type(canvas.getByLabelText("パスワード"), "password"); await userEvent.click(canvas.getByRole("button", { name: "ログイン" })); await waitFor(() => expect(canvas.getByText(/が異なります/)).toBeInTheDocument()); }, };
-
-
-
@@ -0,0 +1,146 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage, type MessageInitShape, type MessageShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { Button, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { AuthenticationErrorSchema } from "@yamori/proto/yamori/error/v1/authentication_error_pb.js"; import { LoginRequestSchema } from "@yamori/proto/yamori/workspace/v2/login_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC } from "react"; import { useForm } from "react-hook-form"; import { ErrorCallout, ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import * as FormField from "../../components/FormField.ts"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { Layout } from "./Layout.tsx"; export interface LoginProps { onLogin?(user: MessageShape<typeof UserSchema>): void; } export const Login: FC<LoginProps> = ({ onLogin }) => { const form = useForm<{ name: string; password: string; }>({ defaultValues: { name: "", password: "", }, mode: "onBlur", }); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const mutation = useMutation({ async mutationFn(req: MessageInitShape<typeof LoginRequestSchema>) { const resp = await client.login(req); if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onLogin?.(user); }, }); return ( <Layout title="ログイン"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit(({ name, password }) => { mutation.mutate({ name, password, }); })} > {mutation.error ? ( isMessage(mutation.error, AuthenticationErrorSchema) ? ( <ErrorCallout title="ログインできませんでした" actions={ <Button size="1" variant="outline" type="button" onClick={() => void mutation.reset()} > 閉じる </Button> } > ユーザ名またはパスワードが異なります。 </ErrorCallout> ) : ( <ManagedErrorCallout error={mutation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void mutation.reset()} > 閉じる </Button> } title="ログインできませんでした" /> ) ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="c_name">ユーザ名</FormField.Label> <TextField.Root id="c_name" disabled={mutation.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_password">パスワード</FormField.Label> <TextField.Root id="c_password" disabled={mutation.isPending} color={form.formState.errors.password ? "red" : undefined} aria-invalid={!!form.formState.errors.password} type="password" autoComplete="current-password" {...form.register("password", { required: "必須です", })} /> </FormField.Root> <Button loading={mutation.isPending}>ログイン</Button> </form> </Flex> </Layout> ); };
-