@gntik-ai/templates 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2420 -0
- package/dist/index.js +14520 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -3
- package/src/accept-invitation/Page.test.tsx +40 -0
- package/src/accept-invitation/Page.tsx +153 -0
- package/src/accept-invitation/data.ts +22 -0
- package/src/accept-invitation/template.meta.ts +12 -0
- package/src/assistant-chat/Page.test.tsx +51 -0
- package/src/assistant-chat/Page.tsx +273 -0
- package/src/assistant-chat/data.ts +71 -0
- package/src/assistant-chat/template.meta.ts +12 -0
- package/src/audit-log/Page.test.tsx +58 -0
- package/src/audit-log/Page.tsx +284 -0
- package/src/audit-log/data.ts +133 -0
- package/src/audit-log/template.meta.ts +12 -0
- package/src/board/Lane.tsx +117 -0
- package/src/board/Page.test.tsx +68 -0
- package/src/board/Page.tsx +178 -0
- package/src/board/data.ts +127 -0
- package/src/board/template.meta.ts +12 -0
- package/src/board/useBoard.ts +146 -0
- package/src/changelog/Page.test.tsx +31 -0
- package/src/changelog/Page.tsx +174 -0
- package/src/changelog/data.ts +105 -0
- package/src/changelog/template.meta.ts +12 -0
- package/src/code-editor/Page.test.tsx +83 -0
- package/src/code-editor/Page.tsx +207 -0
- package/src/code-editor/data.ts +130 -0
- package/src/code-editor/template.meta.ts +12 -0
- package/src/contact/Page.test.tsx +33 -0
- package/src/contact/Page.tsx +272 -0
- package/src/contact/PublicFrame.tsx +84 -0
- package/src/contact/data.ts +129 -0
- package/src/contact/template.meta.ts +12 -0
- package/src/create-wizard/Overlay.test.tsx +68 -0
- package/src/create-wizard/Page.test.tsx +57 -0
- package/src/create-wizard/Page.tsx +246 -0
- package/src/create-wizard/data.ts +139 -0
- package/src/create-wizard/examples/Overlay.tsx +60 -0
- package/src/create-wizard/template.meta.ts +12 -0
- package/src/create-workspace/Page.test.tsx +41 -0
- package/src/create-workspace/Page.tsx +227 -0
- package/src/create-workspace/data.ts +61 -0
- package/src/create-workspace/template.meta.ts +12 -0
- package/src/data-explorer/Page.test.tsx +45 -0
- package/src/data-explorer/Page.tsx +247 -0
- package/src/data-explorer/data.ts +94 -0
- package/src/data-explorer/template.meta.ts +12 -0
- package/src/docs-article/ArticleFeedback.tsx +75 -0
- package/src/docs-article/Page.test.tsx +34 -0
- package/src/docs-article/Page.tsx +200 -0
- package/src/docs-article/data.ts +65 -0
- package/src/docs-article/template.meta.ts +12 -0
- package/src/evaluations/Page.test.tsx +40 -0
- package/src/evaluations/Page.tsx +238 -0
- package/src/evaluations/data.ts +194 -0
- package/src/evaluations/template.meta.ts +12 -0
- package/src/forgot-password/Page.test.tsx +199 -0
- package/src/forgot-password/Page.tsx +66 -0
- package/src/forgot-password/data.ts +5 -0
- package/src/forgot-password/examples/UsernameOrEmail.tsx +25 -0
- package/src/forgot-password/template.meta.ts +12 -0
- package/src/getting-started/Page.test.tsx +27 -0
- package/src/getting-started/Page.tsx +125 -0
- package/src/getting-started/data.ts +67 -0
- package/src/getting-started/template.meta.ts +12 -0
- package/src/help-home/Page.test.tsx +33 -0
- package/src/help-home/Page.tsx +159 -0
- package/src/help-home/data.ts +151 -0
- package/src/help-home/template.meta.ts +12 -0
- package/src/home-dashboard/Page.test.tsx +57 -0
- package/src/home-dashboard/Page.tsx +152 -0
- package/src/home-dashboard/data.ts +66 -0
- package/src/home-dashboard/template.meta.ts +12 -0
- package/src/incident-board/Page.test.tsx +77 -0
- package/src/incident-board/Page.tsx +263 -0
- package/src/incident-board/data.ts +220 -0
- package/src/incident-board/template.meta.ts +12 -0
- package/src/incident-console/Page.test.tsx +49 -0
- package/src/incident-console/Page.tsx +274 -0
- package/src/incident-console/data.ts +176 -0
- package/src/incident-console/template.meta.ts +12 -0
- package/src/index.ts +250 -0
- package/src/keyboard-shortcuts/Page.test.tsx +45 -0
- package/src/keyboard-shortcuts/Page.tsx +94 -0
- package/src/keyboard-shortcuts/ShortcutSheet.tsx +80 -0
- package/src/keyboard-shortcuts/data.ts +84 -0
- package/src/keyboard-shortcuts/template.meta.ts +12 -0
- package/src/landing/Page.test.tsx +27 -0
- package/src/landing/Page.tsx +110 -0
- package/src/landing/data.ts +17 -0
- package/src/landing/template.meta.ts +11 -0
- package/src/live-event-stream/Page.test.tsx +251 -0
- package/src/live-event-stream/Page.tsx +307 -0
- package/src/live-event-stream/data.ts +44 -0
- package/src/live-event-stream/examples/Connecting.tsx +5 -0
- package/src/live-event-stream/examples/Error.tsx +5 -0
- package/src/live-event-stream/examples/LiveEmpty.tsx +5 -0
- package/src/live-event-stream/examples/LiveWithEvents.tsx +6 -0
- package/src/live-event-stream/examples/Stopped.tsx +6 -0
- package/src/live-event-stream/template.meta.ts +74 -0
- package/src/messaging/Page.test.tsx +34 -0
- package/src/messaging/Page.tsx +236 -0
- package/src/messaging/data.ts +118 -0
- package/src/messaging/template.meta.ts +12 -0
- package/src/meta.ts +24 -0
- package/src/mfa/Page.test.tsx +22 -0
- package/src/mfa/Page.tsx +45 -0
- package/src/mfa/data.ts +6 -0
- package/src/mfa/template.meta.ts +12 -0
- package/src/model-providers/Page.test.tsx +58 -0
- package/src/model-providers/Page.tsx +198 -0
- package/src/model-providers/data.ts +101 -0
- package/src/model-providers/template.meta.ts +12 -0
- package/src/notification-inbox/Page.test.tsx +49 -0
- package/src/notification-inbox/Page.tsx +237 -0
- package/src/notification-inbox/data.ts +129 -0
- package/src/notification-inbox/template.meta.ts +12 -0
- package/src/pending-activation/Page.test.tsx +390 -0
- package/src/pending-activation/Page.tsx +145 -0
- package/src/pending-activation/data.ts +25 -0
- package/src/pending-activation/template.meta.ts +12 -0
- package/src/pricing/ComparisonTable.tsx +75 -0
- package/src/pricing/Page.test.tsx +29 -0
- package/src/pricing/Page.tsx +83 -0
- package/src/pricing/PublicFrame.tsx +84 -0
- package/src/pricing/data.ts +104 -0
- package/src/pricing/template.meta.ts +12 -0
- package/src/prompt-playground/Page.test.tsx +72 -0
- package/src/prompt-playground/Page.tsx +254 -0
- package/src/prompt-playground/data.ts +68 -0
- package/src/prompt-playground/template.meta.ts +12 -0
- package/src/public-hub/Page.test.tsx +176 -0
- package/src/public-hub/Page.tsx +125 -0
- package/src/public-hub/Source.test.ts +54 -0
- package/src/public-hub/data.ts +24 -0
- package/src/public-hub/examples/Dark.tsx +11 -0
- package/src/public-hub/examples/Full.tsx +6 -0
- package/src/public-hub/examples/HighContrast.tsx +11 -0
- package/src/public-hub/examples/Light.tsx +11 -0
- package/src/public-hub/examples/Loading.tsx +6 -0
- package/src/public-hub/examples/Minimal.tsx +5 -0
- package/src/public-hub/examples/MinimalLoading.tsx +5 -0
- package/src/public-hub/template.meta.ts +60 -0
- package/src/reset-password/Page.test.tsx +27 -0
- package/src/reset-password/Page.tsx +46 -0
- package/src/reset-password/data.ts +5 -0
- package/src/reset-password/template.meta.ts +12 -0
- package/src/resource-detail/Page.test.tsx +260 -0
- package/src/resource-detail/Page.tsx +262 -0
- package/src/resource-detail/data.ts +55 -0
- package/src/resource-detail/examples/postgres-table-detail.tsx +28 -0
- package/src/resource-detail/examples/realtime-configuration.tsx +26 -0
- package/src/resource-detail/examples/runtime-with-transition.tsx +30 -0
- package/src/resource-detail/examples/runtime-without-transition.tsx +33 -0
- package/src/resource-detail/examples/shell.ts +12 -0
- package/src/resource-detail/template.meta.ts +21 -0
- package/src/resource-form/Page.test.tsx +40 -0
- package/src/resource-form/Page.tsx +186 -0
- package/src/resource-form/data.ts +63 -0
- package/src/resource-form/template.meta.ts +12 -0
- package/src/resource-gallery/Page.test.tsx +42 -0
- package/src/resource-gallery/Page.tsx +125 -0
- package/src/resource-gallery/data.ts +44 -0
- package/src/resource-gallery/template.meta.ts +20 -0
- package/src/resource-index/Page.test.tsx +120 -0
- package/src/resource-index/Page.tsx +216 -0
- package/src/resource-index/data.ts +84 -0
- package/src/resource-index/template.meta.ts +21 -0
- package/src/resource-tree/Page.test.tsx +31 -0
- package/src/resource-tree/Page.tsx +232 -0
- package/src/resource-tree/data.ts +180 -0
- package/src/resource-tree/template.meta.ts +12 -0
- package/src/run-trace/Page.test.tsx +56 -0
- package/src/run-trace/Page.tsx +242 -0
- package/src/run-trace/data.ts +171 -0
- package/src/run-trace/template.meta.ts +12 -0
- package/src/scorecard/Page.test.tsx +38 -0
- package/src/scorecard/Page.tsx +160 -0
- package/src/scorecard/TargetsTable.tsx +92 -0
- package/src/scorecard/data.ts +118 -0
- package/src/scorecard/template.meta.ts +12 -0
- package/src/search-results/Page.test.tsx +43 -0
- package/src/search-results/Page.tsx +243 -0
- package/src/search-results/data.ts +130 -0
- package/src/search-results/template.meta.ts +12 -0
- package/src/settings-api-keys/Page.test.tsx +39 -0
- package/src/settings-api-keys/Page.tsx +171 -0
- package/src/settings-api-keys/data.ts +58 -0
- package/src/settings-api-keys/template.meta.ts +12 -0
- package/src/settings-appearance/Page.test.tsx +44 -0
- package/src/settings-appearance/Page.tsx +92 -0
- package/src/settings-appearance/data.ts +21 -0
- package/src/settings-appearance/template.meta.ts +11 -0
- package/src/settings-billing/Page.test.tsx +31 -0
- package/src/settings-billing/Page.tsx +71 -0
- package/src/settings-billing/data.ts +15 -0
- package/src/settings-billing/template.meta.ts +19 -0
- package/src/settings-integrations/Page.test.tsx +31 -0
- package/src/settings-integrations/Page.tsx +185 -0
- package/src/settings-integrations/data.ts +116 -0
- package/src/settings-integrations/template.meta.ts +11 -0
- package/src/settings-members/Page.test.tsx +32 -0
- package/src/settings-members/Page.tsx +140 -0
- package/src/settings-members/data.ts +28 -0
- package/src/settings-members/template.meta.ts +12 -0
- package/src/settings-notifications/Page.test.tsx +31 -0
- package/src/settings-notifications/Page.tsx +71 -0
- package/src/settings-notifications/data.ts +12 -0
- package/src/settings-notifications/template.meta.ts +11 -0
- package/src/settings-profile/Baseline.test.tsx +9 -0
- package/src/settings-profile/Editable.test.tsx +21 -0
- package/src/settings-profile/Page.test.tsx +41 -0
- package/src/settings-profile/Page.tsx +133 -0
- package/src/settings-profile/ReadOnly.test.tsx +143 -0
- package/src/settings-profile/ReadOnlyProfile.tsx +85 -0
- package/src/settings-profile/SettingsFrame.test.tsx +55 -0
- package/src/settings-profile/SettingsFrame.tsx +88 -0
- package/src/settings-profile/__tests__/dom.ts +8 -0
- package/src/settings-profile/data.ts +41 -0
- package/src/settings-profile/examples/ReadOnly.tsx +31 -0
- package/src/settings-profile/fixtures/editable.snap +1 -0
- package/src/settings-profile/settings-nav.ts +98 -0
- package/src/settings-profile/template.meta.ts +21 -0
- package/src/settings-security/MfaSetupDialog.tsx +96 -0
- package/src/settings-security/Page.test.tsx +44 -0
- package/src/settings-security/Page.tsx +158 -0
- package/src/settings-security/data.ts +11 -0
- package/src/settings-security/template.meta.ts +11 -0
- package/src/settings-workspace/Page.test.tsx +44 -0
- package/src/settings-workspace/Page.tsx +120 -0
- package/src/settings-workspace/data.ts +44 -0
- package/src/settings-workspace/template.meta.ts +19 -0
- package/src/shared/ConsoleShell.test.tsx +81 -0
- package/src/shared/ConsoleShell.tsx +98 -0
- package/src/shell-sidebar/Page.test.tsx +21 -0
- package/src/shell-sidebar/Page.tsx +85 -0
- package/src/shell-sidebar/data.ts +24 -0
- package/src/shell-sidebar/template.meta.ts +12 -0
- package/src/shell-topnav/Page.test.tsx +25 -0
- package/src/shell-topnav/Page.tsx +94 -0
- package/src/shell-topnav/data.ts +25 -0
- package/src/shell-topnav/template.meta.ts +12 -0
- package/src/sign-in/BrandPanel.tsx +34 -0
- package/src/sign-in/Page.test.tsx +41 -0
- package/src/sign-in/Page.tsx +73 -0
- package/src/sign-in/data.ts +11 -0
- package/src/sign-in/template.meta.ts +12 -0
- package/src/sign-in-card/Page.test.tsx +182 -0
- package/src/sign-in-card/Page.tsx +82 -0
- package/src/sign-in-card/data.ts +8 -0
- package/src/sign-in-card/examples/Username.tsx +28 -0
- package/src/sign-in-card/template.meta.ts +12 -0
- package/src/sign-up/Page.test.tsx +33 -0
- package/src/sign-up/Page.tsx +70 -0
- package/src/sign-up/data.ts +15 -0
- package/src/sign-up/template.meta.ts +12 -0
- package/src/sso-login/Page.test.tsx +41 -0
- package/src/sso-login/Page.tsx +151 -0
- package/src/sso-login/data.ts +10 -0
- package/src/sso-login/template.meta.ts +12 -0
- package/src/status-403/Page.test.tsx +23 -0
- package/src/status-403/Page.tsx +92 -0
- package/src/status-403/data.ts +13 -0
- package/src/status-403/template.meta.ts +12 -0
- package/src/status-404/Page.test.tsx +23 -0
- package/src/status-404/Page.tsx +60 -0
- package/src/status-404/PageHeading.test.tsx +52 -0
- package/src/status-404/data.ts +12 -0
- package/src/status-404/examples/PageHeader.tsx +28 -0
- package/src/status-404/template.meta.ts +12 -0
- package/src/status-500/Page.test.tsx +24 -0
- package/src/status-500/Page.tsx +70 -0
- package/src/status-500/data.ts +20 -0
- package/src/status-500/template.meta.ts +12 -0
- package/src/status-maintenance/Page.test.tsx +24 -0
- package/src/status-maintenance/Page.tsx +84 -0
- package/src/status-maintenance/data.ts +14 -0
- package/src/status-maintenance/template.meta.ts +12 -0
- package/src/styles.css +11 -0
- package/src/test/a11y.ts +18 -0
- package/src/test/setup.ts +36 -0
- package/src/upgrade-required/Page.test.tsx +28 -0
- package/src/upgrade-required/Page.tsx +79 -0
- package/src/upgrade-required/data.ts +19 -0
- package/src/upgrade-required/template.meta.ts +12 -0
- package/src/usage-analytics/Page.test.tsx +83 -0
- package/src/usage-analytics/Page.tsx +184 -0
- package/src/usage-analytics/data.ts +161 -0
- package/src/usage-analytics/template.meta.ts +21 -0
- package/src/verify-email/Page.test.tsx +32 -0
- package/src/verify-email/Page.tsx +118 -0
- package/src/verify-email/data.ts +7 -0
- package/src/verify-email/template.meta.ts +12 -0
- package/src/workflow-builder/Page.test.tsx +97 -0
- package/src/workflow-builder/Page.tsx +163 -0
- package/src/workflow-builder/data.ts +13 -0
- package/src/workflow-builder/template.meta.ts +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ThemeProvider } from '@gntik-ai/ui';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { createRef } from 'react';
|
|
5
|
+
import type { SignInFormRef } from '@gntik-ai/blocks';
|
|
6
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
7
|
+
import SignInCardPage from './Page';
|
|
8
|
+
|
|
9
|
+
describe('SignInCardPage', () => {
|
|
10
|
+
it('suppresses every internal heading beside a page heading and hides or replaces optional regions', () => {
|
|
11
|
+
const { container, rerender } = render(
|
|
12
|
+
<>
|
|
13
|
+
<h1>Account access</h1>
|
|
14
|
+
<SignInCardPage suppressAllHeadings footer={null} signUpPrompt={null} sso={null} />
|
|
15
|
+
</>,
|
|
16
|
+
);
|
|
17
|
+
expect(container.querySelectorAll('h1')).toHaveLength(1);
|
|
18
|
+
expect(screen.getByRole('main').querySelector('h1,h2,h3,h4,h5,h6,[role="heading"]')).toBeNull();
|
|
19
|
+
expect(screen.getByRole('form', { name: 'Sign in' })).toBeInTheDocument();
|
|
20
|
+
expect(screen.queryByRole('link', { name: 'Terms' })).not.toBeInTheDocument();
|
|
21
|
+
expect(screen.queryByRole('link', { name: 'Privacy Policy' })).not.toBeInTheDocument();
|
|
22
|
+
expect(screen.queryByRole('link', { name: 'Create an account' })).not.toBeInTheDocument();
|
|
23
|
+
expect(screen.queryByRole('group', { name: 'Single sign-on' })).not.toBeInTheDocument();
|
|
24
|
+
rerender(<SignInCardPage headingLevel="h2" footer="Account support" />);
|
|
25
|
+
expect(screen.getByRole('heading', { level: 2, name: 'Sign in' })).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Account support')).toBeInTheDocument();
|
|
27
|
+
rerender(<SignInCardPage headingLevel={null} aria-label="Account access" />);
|
|
28
|
+
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
29
|
+
expect(screen.getByRole('form', { name: 'Account access' })).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('forwards identifier, validation, field help, focus and server ARIA props unchanged', async () => {
|
|
33
|
+
const onSubmit = vi.fn();
|
|
34
|
+
const ref = createRef<SignInFormRef>();
|
|
35
|
+
const props = {
|
|
36
|
+
ref,
|
|
37
|
+
onSubmit,
|
|
38
|
+
identifier: {
|
|
39
|
+
type: 'text' as const,
|
|
40
|
+
autoComplete: 'username',
|
|
41
|
+
label: 'Username',
|
|
42
|
+
help: 'Account name',
|
|
43
|
+
helpId: 'username-help',
|
|
44
|
+
minLength: 3,
|
|
45
|
+
maxLength: 120,
|
|
46
|
+
},
|
|
47
|
+
passwordHelp: 'Account password',
|
|
48
|
+
passwordHelpId: 'password-help',
|
|
49
|
+
messages: {
|
|
50
|
+
identifierRequired: 'Username is required.',
|
|
51
|
+
identifierInvalid: 'Use three characters.',
|
|
52
|
+
passwordRequired: 'Password is required.',
|
|
53
|
+
},
|
|
54
|
+
'aria-describedby': 'form-feedback',
|
|
55
|
+
};
|
|
56
|
+
const { rerender } = render(<SignInCardPage {...props} />);
|
|
57
|
+
const input = screen.getByLabelText('Username');
|
|
58
|
+
const password = screen.getByLabelText('Password');
|
|
59
|
+
expect(input).toHaveAttribute('type', 'text');
|
|
60
|
+
expect(input).toHaveAttribute('autocomplete', 'username');
|
|
61
|
+
expect(input).toHaveAttribute('minlength', '3');
|
|
62
|
+
expect(input).toHaveAttribute('maxlength', '120');
|
|
63
|
+
expect(screen.getByRole('form', { name: 'Sign in' })).toHaveAttribute('novalidate');
|
|
64
|
+
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
|
65
|
+
expect(input).toHaveFocus();
|
|
66
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
67
|
+
for (const [field, message, helpId] of [
|
|
68
|
+
[input, 'Username is required.', 'username-help'],
|
|
69
|
+
[password, 'Password is required.', 'password-help'],
|
|
70
|
+
] as const) {
|
|
71
|
+
const error = screen.getByText(message).closest('[role="alert"]');
|
|
72
|
+
expect(field).toHaveAttribute('aria-invalid', 'true');
|
|
73
|
+
expect(field.getAttribute('aria-describedby')?.split(' ')).toEqual(expect.arrayContaining([helpId, error?.id]));
|
|
74
|
+
}
|
|
75
|
+
await userEvent.type(input, 'ab');
|
|
76
|
+
expect(screen.getByText('Use three characters.')).toBeInTheDocument();
|
|
77
|
+
await userEvent.type(input, 'c');
|
|
78
|
+
await userEvent.type(password, 'secret');
|
|
79
|
+
expect(input).not.toHaveAttribute('aria-invalid');
|
|
80
|
+
expect(password).not.toHaveAttribute('aria-invalid');
|
|
81
|
+
rerender(
|
|
82
|
+
<SignInCardPage
|
|
83
|
+
{...props}
|
|
84
|
+
fieldInvalid={{ identifier: true, password: true }}
|
|
85
|
+
feedbackId="credential-feedback"
|
|
86
|
+
error="Credentials were not accepted."
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
for (const [field, helpId] of [
|
|
90
|
+
[input, 'username-help'],
|
|
91
|
+
[password, 'password-help'],
|
|
92
|
+
] as const) {
|
|
93
|
+
expect(field).toHaveAttribute('aria-invalid', 'true');
|
|
94
|
+
expect(field.getAttribute('aria-describedby')?.split(' ')).toEqual(
|
|
95
|
+
expect.arrayContaining([helpId, 'credential-feedback']),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
expect(screen.getByRole('form', { name: 'Sign in' })).toHaveAttribute('aria-describedby', 'form-feedback');
|
|
99
|
+
input.focus();
|
|
100
|
+
ref.current?.focus('password');
|
|
101
|
+
expect(password).toHaveFocus();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('renders the card form without SSO', async () => {
|
|
105
|
+
const { container } = render(<SignInCardPage />);
|
|
106
|
+
expect(screen.getByRole('main')).toBeInTheDocument();
|
|
107
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
|
|
108
|
+
expect(screen.queryByRole('group', { name: 'Single sign-on' })).not.toBeInTheDocument();
|
|
109
|
+
expect(screen.getByRole('link', { name: 'Privacy Policy' })).toBeInTheDocument();
|
|
110
|
+
await expectNoAxeViolations(container);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('shows a server error and runs forgot password', async () => {
|
|
114
|
+
const onForgotPassword = vi.fn();
|
|
115
|
+
render(<SignInCardPage error="Incorrect email or password." onForgotPassword={onForgotPassword} />);
|
|
116
|
+
expect(screen.getByText('Incorrect email or password.')).toBeInTheDocument();
|
|
117
|
+
await userEvent.click(screen.getByRole('link', { name: 'Forgot password?' }));
|
|
118
|
+
expect(onForgotPassword).toHaveBeenCalledOnce();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('takes the heading copy from props', () => {
|
|
122
|
+
render(<SignInCardPage title="Welcome back to the console" description="Sign in with your tenant account." />);
|
|
123
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Welcome back to the console' })).toBeInTheDocument();
|
|
124
|
+
expect(screen.getByText('Sign in with your tenant account.')).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Public rendering seams: identifier mode, theme, heading policy and validation/recovery state.
|
|
129
|
+
describe('SignInCardPage accessibility matrix', () => {
|
|
130
|
+
for (const theme of ['dark', 'light', 'high_contrast'] as const) {
|
|
131
|
+
for (const mode of ['email', 'username'] as const) {
|
|
132
|
+
for (const suppressed of [false, true]) {
|
|
133
|
+
for (const state of ['initial', 'errors'] as const) {
|
|
134
|
+
it(`${theme} / ${mode} / headings ${!suppressed} / ${state}`, async () => {
|
|
135
|
+
const onSubmit = vi.fn();
|
|
136
|
+
const { container } = render(
|
|
137
|
+
<ThemeProvider defaultMode={theme} storageKey={null}>
|
|
138
|
+
<h1>Account access</h1>
|
|
139
|
+
<SignInCardPage
|
|
140
|
+
identifier={
|
|
141
|
+
mode === 'username'
|
|
142
|
+
? {
|
|
143
|
+
type: 'text',
|
|
144
|
+
autoComplete: 'username',
|
|
145
|
+
label: 'Username',
|
|
146
|
+
help: 'Use your account name.',
|
|
147
|
+
minLength: 3,
|
|
148
|
+
maxLength: 120,
|
|
149
|
+
}
|
|
150
|
+
: undefined
|
|
151
|
+
}
|
|
152
|
+
suppressAllHeadings={suppressed}
|
|
153
|
+
messages={{
|
|
154
|
+
identifierRequired: 'Account name is required.',
|
|
155
|
+
passwordRequired: 'Password is required.',
|
|
156
|
+
}}
|
|
157
|
+
onSubmit={onSubmit}
|
|
158
|
+
/>
|
|
159
|
+
</ThemeProvider>,
|
|
160
|
+
);
|
|
161
|
+
if (state === 'initial' || state === 'errors') {
|
|
162
|
+
const input = screen.getByRole('textbox', { name: mode === 'username' ? 'Username' : 'Email' });
|
|
163
|
+
expect(input).toHaveAttribute('type', mode === 'username' ? 'text' : 'email');
|
|
164
|
+
expect(input).toHaveAttribute('autocomplete', mode === 'username' ? 'username' : 'email');
|
|
165
|
+
expect(screen.getByRole('form', { name: 'Sign in' })).toHaveAttribute('novalidate');
|
|
166
|
+
if (state === 'errors') {
|
|
167
|
+
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
|
168
|
+
expect(input).toHaveFocus();
|
|
169
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
170
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
171
|
+
expect(screen.getByText('Account name is required.')).toBeInTheDocument();
|
|
172
|
+
expect(screen.getByLabelText('Password')).toHaveAttribute('aria-invalid', 'true');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (suppressed) expect(container.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]')).toHaveLength(1);
|
|
176
|
+
await expectNoAxeViolations(container);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { SignInForm, type SignInValues, type SignInFormProps } from '@gntik-ai/blocks';
|
|
2
|
+
import { AuthLayout, Link } from '@gntik-ai/ui';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { signInCardCopy } from './data';
|
|
5
|
+
|
|
6
|
+
export interface SignInCardPageProps extends SignInFormProps {
|
|
7
|
+
onSubmit: (values: SignInValues) => void | Promise<void>;
|
|
8
|
+
error: string | null;
|
|
9
|
+
defaultEmail: string;
|
|
10
|
+
forgotPasswordHref: string;
|
|
11
|
+
onForgotPassword: () => void;
|
|
12
|
+
signUpHref: string;
|
|
13
|
+
onSignUp: () => void;
|
|
14
|
+
/** Optional SSO area under the form; hidden by default. */
|
|
15
|
+
sso: ReactNode;
|
|
16
|
+
eyebrow: ReactNode;
|
|
17
|
+
title: ReactNode;
|
|
18
|
+
description: ReactNode;
|
|
19
|
+
termsHref: string;
|
|
20
|
+
privacyHref: string;
|
|
21
|
+
logo: ReactNode;
|
|
22
|
+
/** Replaces the legal footer; `null` hides it. */
|
|
23
|
+
footer?: ReactNode;
|
|
24
|
+
/** Suppresses all internal headings, including the embedded form heading. */
|
|
25
|
+
suppressAllHeadings?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Card sign-in: AuthLayout `card` + SignInForm without SSO. */
|
|
29
|
+
export default function SignInCardPage({
|
|
30
|
+
onSubmit,
|
|
31
|
+
error,
|
|
32
|
+
defaultEmail,
|
|
33
|
+
forgotPasswordHref,
|
|
34
|
+
onForgotPassword,
|
|
35
|
+
signUpHref,
|
|
36
|
+
onSignUp,
|
|
37
|
+
sso = null,
|
|
38
|
+
eyebrow = signInCardCopy.eyebrow,
|
|
39
|
+
title = signInCardCopy.title,
|
|
40
|
+
description = signInCardCopy.description,
|
|
41
|
+
termsHref = signInCardCopy.termsHref,
|
|
42
|
+
privacyHref = signInCardCopy.privacyHref,
|
|
43
|
+
logo,
|
|
44
|
+
footer,
|
|
45
|
+
suppressAllHeadings = false,
|
|
46
|
+
headingLevel,
|
|
47
|
+
...formProps
|
|
48
|
+
}: Partial<SignInCardPageProps>) {
|
|
49
|
+
return (
|
|
50
|
+
<AuthLayout
|
|
51
|
+
fullScreen
|
|
52
|
+
variant="card"
|
|
53
|
+
logo={logo}
|
|
54
|
+
footer={
|
|
55
|
+
footer === undefined ? (
|
|
56
|
+
<>
|
|
57
|
+
By signing in you agree to the <Link href={termsHref}>Terms</Link> and{' '}
|
|
58
|
+
<Link href={privacyHref}>Privacy Policy</Link>.
|
|
59
|
+
</>
|
|
60
|
+
) : (
|
|
61
|
+
footer
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
>
|
|
65
|
+
<SignInForm
|
|
66
|
+
{...formProps}
|
|
67
|
+
headingLevel={suppressAllHeadings ? null : headingLevel}
|
|
68
|
+
onSubmit={onSubmit}
|
|
69
|
+
error={error}
|
|
70
|
+
defaultEmail={defaultEmail}
|
|
71
|
+
forgotPasswordHref={forgotPasswordHref}
|
|
72
|
+
onForgotPassword={onForgotPassword}
|
|
73
|
+
signUpHref={signUpHref}
|
|
74
|
+
onSignUp={onSignUp}
|
|
75
|
+
sso={sso}
|
|
76
|
+
eyebrow={eyebrow}
|
|
77
|
+
title={title}
|
|
78
|
+
description={description}
|
|
79
|
+
/>
|
|
80
|
+
</AuthLayout>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import SignInCardPage, { type SignInCardPageProps } from '../Page';
|
|
2
|
+
|
|
3
|
+
/** /preview.html?kind=template&id=sign-in-card--username */
|
|
4
|
+
export default function Username(props: Pick<SignInCardPageProps, 'headingLevel' | 'suppressAllHeadings'>) {
|
|
5
|
+
return (
|
|
6
|
+
<SignInCardPage
|
|
7
|
+
{...props}
|
|
8
|
+
identifier={{
|
|
9
|
+
type: 'text',
|
|
10
|
+
autoComplete: 'username',
|
|
11
|
+
label: 'Username',
|
|
12
|
+
help: 'Use your account name.',
|
|
13
|
+
placeholder: 'Account name',
|
|
14
|
+
minLength: 3,
|
|
15
|
+
maxLength: 120,
|
|
16
|
+
}}
|
|
17
|
+
messages={{
|
|
18
|
+
identifierRequired: 'Enter your username.',
|
|
19
|
+
identifierInvalid: 'Use at least three characters.',
|
|
20
|
+
passwordRequired: 'Enter your password.',
|
|
21
|
+
}}
|
|
22
|
+
description="Use your account name to continue."
|
|
23
|
+
footer={null}
|
|
24
|
+
signUpPrompt={null}
|
|
25
|
+
sso={null}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Sign in (card)',
|
|
5
|
+
family: 'Auth',
|
|
6
|
+
priority: 'P1',
|
|
7
|
+
status: 'stable',
|
|
8
|
+
description:
|
|
9
|
+
'Centred card sign-in with default email and password, legal footer and no SSO. Supports identifier (type, autoComplete, label, help/helpId, placeholder, minLength/maxLength), messages (identifierRequired/identifierInvalid/identifierTooShort/passwordRequired), and React 19 ref.focus. The kit owns inline validation and focuses the first invalid field; consumers supply messages, not a second inline validator. Server errors use error + fieldInvalid + feedbackId; aria-describedby describes the form, while flagged inputs also retain their help and error ids. headingLevel (h1/h2/h3/null) suppresses the heading without losing the form name; aria-label/aria-labelledby override the default name. Forwards SignInForm props unchanged, including passwordHelp/passwordHelpId and ref.focus(identifier/password). Text submissions retain email and add identifier. suppressAllHeadings suppresses every internal heading. footer, sso and signUpPrompt accept replacements or null. Preview: sign-in-card--username.',
|
|
10
|
+
layout: 'AuthLayout',
|
|
11
|
+
blocks: ['sign-in-form'],
|
|
12
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
4
|
+
import SignUpPage from './Page';
|
|
5
|
+
|
|
6
|
+
describe('SignUpPage', () => {
|
|
7
|
+
it('renders the form, SSO options and brand panel', async () => {
|
|
8
|
+
const { container } = render(<SignUpPage />);
|
|
9
|
+
expect(screen.getByRole('main')).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByRole('complementary')).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Create your account' })).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByRole('group', { name: 'Single sign-on' })).toBeInTheDocument();
|
|
13
|
+
await expectNoAxeViolations(container);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('requires the terms, then creates the account', async () => {
|
|
17
|
+
const onSubmit = vi.fn();
|
|
18
|
+
render(<SignUpPage onSubmit={onSubmit} />);
|
|
19
|
+
await userEvent.type(screen.getByLabelText('Full name'), 'Avery Collins');
|
|
20
|
+
await userEvent.type(screen.getByLabelText('Work email'), 'avery@example.com');
|
|
21
|
+
await userEvent.type(screen.getByLabelText('Password'), 'long-enough-pass');
|
|
22
|
+
await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
|
|
23
|
+
expect(screen.getByText('Accept the terms to continue.')).toBeInTheDocument();
|
|
24
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
25
|
+
await userEvent.click(screen.getByRole('checkbox'));
|
|
26
|
+
await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
|
|
27
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
28
|
+
name: 'Avery Collins',
|
|
29
|
+
email: 'avery@example.com',
|
|
30
|
+
password: 'long-enough-pass',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SignUpForm, SsoButtons, type SignUpValues, type SsoProvider } from '@gntik-ai/blocks';
|
|
2
|
+
import { AuthLayout } from '@gntik-ai/ui';
|
|
3
|
+
import { useState, type ReactNode } from 'react';
|
|
4
|
+
import { BrandPanel, type BrandPanelCopy } from '../sign-in/BrandPanel';
|
|
5
|
+
import { signUpBrand, signUpSsoProviders } from './data';
|
|
6
|
+
|
|
7
|
+
export interface SignUpPageProps {
|
|
8
|
+
/** Creates the account. A returned promise shows the loading state; a rejection shows in the alert. */
|
|
9
|
+
onSubmit: (values: SignUpValues) => void | Promise<void>;
|
|
10
|
+
/** Starts single sign-on with a provider id. */
|
|
11
|
+
onSso: (providerId: string) => void;
|
|
12
|
+
ssoProviders: SsoProvider[];
|
|
13
|
+
error: string | null;
|
|
14
|
+
minPasswordLength: number;
|
|
15
|
+
termsHref: string;
|
|
16
|
+
privacyHref: string;
|
|
17
|
+
signInHref: string;
|
|
18
|
+
onSignIn: () => void;
|
|
19
|
+
brand: BrandPanelCopy;
|
|
20
|
+
brandFooter: ReactNode;
|
|
21
|
+
logo: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Split sign-up: AuthLayout brand panel + SignUpForm with SsoButtons. */
|
|
25
|
+
export default function SignUpPage({
|
|
26
|
+
onSubmit,
|
|
27
|
+
onSso,
|
|
28
|
+
ssoProviders = signUpSsoProviders,
|
|
29
|
+
error,
|
|
30
|
+
minPasswordLength,
|
|
31
|
+
termsHref,
|
|
32
|
+
privacyHref,
|
|
33
|
+
signInHref,
|
|
34
|
+
onSignIn,
|
|
35
|
+
brand = signUpBrand,
|
|
36
|
+
brandFooter = signUpBrand.footer,
|
|
37
|
+
logo,
|
|
38
|
+
}: Partial<SignUpPageProps>) {
|
|
39
|
+
const [ssoLoading, setSsoLoading] = useState<string | null>(null);
|
|
40
|
+
return (
|
|
41
|
+
<AuthLayout
|
|
42
|
+
fullScreen
|
|
43
|
+
variant="split"
|
|
44
|
+
logo={logo}
|
|
45
|
+
brand={<BrandPanel {...brand} />}
|
|
46
|
+
brandFooter={brandFooter}
|
|
47
|
+
skipLinkLabel="Skip to sign-up form"
|
|
48
|
+
>
|
|
49
|
+
<SignUpForm
|
|
50
|
+
onSubmit={onSubmit}
|
|
51
|
+
error={error}
|
|
52
|
+
minPasswordLength={minPasswordLength}
|
|
53
|
+
termsHref={termsHref}
|
|
54
|
+
privacyHref={privacyHref}
|
|
55
|
+
signInHref={signInHref}
|
|
56
|
+
onSignIn={onSignIn}
|
|
57
|
+
sso={
|
|
58
|
+
<SsoButtons
|
|
59
|
+
providers={ssoProviders}
|
|
60
|
+
loadingId={ssoLoading}
|
|
61
|
+
onSelect={(id) => {
|
|
62
|
+
setSsoLoading(id);
|
|
63
|
+
onSso?.(id);
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
}
|
|
67
|
+
/>
|
|
68
|
+
</AuthLayout>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ssoProviders } from '@gntik-ai/blocks';
|
|
2
|
+
|
|
3
|
+
export { ssoProviders as signUpSsoProviders };
|
|
4
|
+
|
|
5
|
+
/** Brand panel copy for the split layout. */
|
|
6
|
+
export const signUpBrand = {
|
|
7
|
+
eyebrow: 'Free for small teams',
|
|
8
|
+
headline: 'Your whole team, one workspace.',
|
|
9
|
+
features: [
|
|
10
|
+
'Invite teammates with roles from day one',
|
|
11
|
+
'Projects, environments and deployments together',
|
|
12
|
+
'Usage-based plans, no card to start',
|
|
13
|
+
],
|
|
14
|
+
footer: 'SOC 2 · encrypted at rest · audited access',
|
|
15
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Sign up',
|
|
5
|
+
family: 'Auth',
|
|
6
|
+
priority: 'P1',
|
|
7
|
+
status: 'stable',
|
|
8
|
+
description:
|
|
9
|
+
'Split sign-up: brand panel with the value pitch, name, work email and password with a strength meter, terms consent and single sign-on.',
|
|
10
|
+
layout: 'AuthLayout',
|
|
11
|
+
blocks: ['sign-up-form', 'sso-buttons'],
|
|
12
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
4
|
+
import SsoLoginPage from './Page';
|
|
5
|
+
|
|
6
|
+
describe('SsoLoginPage', () => {
|
|
7
|
+
it('renders the work email step', async () => {
|
|
8
|
+
const { container } = render(<SsoLoginPage />);
|
|
9
|
+
expect(screen.getByRole('main')).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Sign in with SSO' })).toBeInTheDocument();
|
|
11
|
+
await expectNoAxeViolations(container);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('looks up the email and shows the redirecting state', async () => {
|
|
15
|
+
const onSubmit = vi.fn();
|
|
16
|
+
const onCancel = vi.fn();
|
|
17
|
+
const { container } = render(<SsoLoginPage onSubmit={onSubmit} onCancel={onCancel} providerName="Acme Identity" />);
|
|
18
|
+
await userEvent.click(screen.getByRole('button', { name: 'Continue with SSO' }));
|
|
19
|
+
expect(screen.getByText('Enter a valid work email.')).toBeInTheDocument();
|
|
20
|
+
await userEvent.type(screen.getByLabelText('Work email'), 'dana@acme.example');
|
|
21
|
+
await userEvent.click(screen.getByRole('button', { name: 'Continue with SSO' }));
|
|
22
|
+
expect(onSubmit).toHaveBeenCalledWith('dana@acme.example');
|
|
23
|
+
expect(await screen.findByRole('heading', { level: 1, name: 'Redirecting to Acme Identity' })).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByRole('status')).toHaveTextContent('Waiting for Acme Identity');
|
|
25
|
+
await expectNoAxeViolations(container);
|
|
26
|
+
await userEvent.click(screen.getByRole('button', { name: 'Use a different email' }));
|
|
27
|
+
expect(onCancel).toHaveBeenCalledOnce();
|
|
28
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Sign in with SSO' })).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('shows a lookup failure', async () => {
|
|
32
|
+
render(
|
|
33
|
+
<SsoLoginPage
|
|
34
|
+
defaultEmail="dana@unknown.example"
|
|
35
|
+
onSubmit={() => Promise.reject(new Error('No SSO connection for this domain.'))}
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
await userEvent.click(screen.getByRole('button', { name: 'Continue with SSO' }));
|
|
39
|
+
expect(await screen.findByText('No SSO connection for this domain.')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { AuthHeader } from '@gntik-ai/blocks';
|
|
2
|
+
import { Building2 } from '@gntik-ai/icons';
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
AuthLayout,
|
|
6
|
+
Button,
|
|
7
|
+
Field,
|
|
8
|
+
FieldDescription,
|
|
9
|
+
FieldError,
|
|
10
|
+
FieldLabel,
|
|
11
|
+
Input,
|
|
12
|
+
Link,
|
|
13
|
+
Spinner,
|
|
14
|
+
Stack,
|
|
15
|
+
Text,
|
|
16
|
+
} from '@gntik-ai/ui';
|
|
17
|
+
import { useState, type ReactNode } from 'react';
|
|
18
|
+
import { ssoEmailPattern, ssoLoginSample } from './data';
|
|
19
|
+
|
|
20
|
+
export interface SsoLoginPageProps {
|
|
21
|
+
/**
|
|
22
|
+
* Looks up the domain's identity provider and starts the redirect. A rejection's message shows
|
|
23
|
+
* in the alert (e.g. "No SSO connection for this domain").
|
|
24
|
+
*/
|
|
25
|
+
onSubmit: (email: string) => void | Promise<void>;
|
|
26
|
+
/** Leaves the redirecting state. */
|
|
27
|
+
onCancel: () => void;
|
|
28
|
+
defaultEmail: string;
|
|
29
|
+
/** Name shown while redirecting. */
|
|
30
|
+
providerName: string;
|
|
31
|
+
signInHref: string;
|
|
32
|
+
error: string | null;
|
|
33
|
+
logo: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Card SSO entry: work email → "Redirecting to your identity provider". */
|
|
37
|
+
export default function SsoLoginPage({
|
|
38
|
+
onSubmit,
|
|
39
|
+
onCancel,
|
|
40
|
+
defaultEmail = ssoLoginSample.defaultEmail,
|
|
41
|
+
providerName = ssoLoginSample.providerName,
|
|
42
|
+
signInHref = ssoLoginSample.signInHref,
|
|
43
|
+
error: errorProp = null,
|
|
44
|
+
logo,
|
|
45
|
+
}: Partial<SsoLoginPageProps>) {
|
|
46
|
+
const [email, setEmail] = useState(defaultEmail);
|
|
47
|
+
const [submitted, setSubmitted] = useState(false);
|
|
48
|
+
const [loading, setLoading] = useState(false);
|
|
49
|
+
const [redirecting, setRedirecting] = useState(false);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const invalid = submitted && !ssoEmailPattern.test(email.trim());
|
|
52
|
+
const domain = email.trim().split('@')[1] ?? '';
|
|
53
|
+
const alert = errorProp ?? error;
|
|
54
|
+
|
|
55
|
+
const start = async () => {
|
|
56
|
+
setError(null);
|
|
57
|
+
setLoading(true);
|
|
58
|
+
try {
|
|
59
|
+
await onSubmit?.(email.trim());
|
|
60
|
+
setRedirecting(true);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
setError(e instanceof Error ? e.message : 'Single sign-on could not start. Try again.');
|
|
63
|
+
}
|
|
64
|
+
setLoading(false);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const footer = (
|
|
68
|
+
<>
|
|
69
|
+
Not using SSO? <Link href={signInHref}>Sign in with a password</Link>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<AuthLayout fullScreen variant="card" logo={logo} skipLinkLabel="Skip to single sign-on" footer={footer}>
|
|
75
|
+
{redirecting ? (
|
|
76
|
+
<Stack gap={6}>
|
|
77
|
+
<AuthHeader
|
|
78
|
+
className="mb-0"
|
|
79
|
+
eyebrow="Single sign-on"
|
|
80
|
+
title={`Redirecting to ${providerName}`}
|
|
81
|
+
description={
|
|
82
|
+
<>
|
|
83
|
+
<span className="font-medium text-foreground">{domain}</span> signs in through {providerName}. If
|
|
84
|
+
nothing happens in a few seconds, try again.
|
|
85
|
+
</>
|
|
86
|
+
}
|
|
87
|
+
/>
|
|
88
|
+
<Stack direction="row" align="center" gap={3} role="status">
|
|
89
|
+
<Spinner size={18} />
|
|
90
|
+
<Text variant="supporting" tone="muted">
|
|
91
|
+
Waiting for {providerName}…
|
|
92
|
+
</Text>
|
|
93
|
+
</Stack>
|
|
94
|
+
<Stack gap={2}>
|
|
95
|
+
<Button variant="secondary" size="lg" className="w-full" onClick={() => void start()} loading={loading}>
|
|
96
|
+
Try again
|
|
97
|
+
</Button>
|
|
98
|
+
<Button
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="lg"
|
|
101
|
+
className="w-full"
|
|
102
|
+
onClick={() => {
|
|
103
|
+
setRedirecting(false);
|
|
104
|
+
onCancel?.();
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
Use a different email
|
|
108
|
+
</Button>
|
|
109
|
+
</Stack>
|
|
110
|
+
</Stack>
|
|
111
|
+
) : (
|
|
112
|
+
<>
|
|
113
|
+
<AuthHeader
|
|
114
|
+
eyebrow="Single sign-on"
|
|
115
|
+
title="Sign in with SSO"
|
|
116
|
+
description="Enter your work email and we'll send you to your organization's identity provider."
|
|
117
|
+
/>
|
|
118
|
+
<form
|
|
119
|
+
noValidate
|
|
120
|
+
aria-label="Single sign-on"
|
|
121
|
+
className="flex flex-col gap-4"
|
|
122
|
+
onSubmit={(e) => {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
setSubmitted(true);
|
|
125
|
+
if (ssoEmailPattern.test(email.trim())) void start();
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{alert && <Alert tone="destructive" description={alert} />}
|
|
129
|
+
<Field invalid={invalid}>
|
|
130
|
+
<FieldLabel>Work email</FieldLabel>
|
|
131
|
+
<Input
|
|
132
|
+
type="email"
|
|
133
|
+
leadingIcon={Building2}
|
|
134
|
+
autoComplete="email"
|
|
135
|
+
placeholder="name@company.com"
|
|
136
|
+
value={email}
|
|
137
|
+
onValueChange={setEmail}
|
|
138
|
+
required
|
|
139
|
+
/>
|
|
140
|
+
<FieldDescription>Your domain decides where you sign in.</FieldDescription>
|
|
141
|
+
<FieldError match={invalid}>Enter a valid work email.</FieldError>
|
|
142
|
+
</Field>
|
|
143
|
+
<Button type="submit" size="lg" loading={loading} className="mt-2 w-full">
|
|
144
|
+
Continue with SSO
|
|
145
|
+
</Button>
|
|
146
|
+
</form>
|
|
147
|
+
</>
|
|
148
|
+
)}
|
|
149
|
+
</AuthLayout>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Sample copy for the single sign-on lookup. */
|
|
2
|
+
export const ssoLoginSample = {
|
|
3
|
+
defaultEmail: '',
|
|
4
|
+
signInHref: '#sign-in',
|
|
5
|
+
/** Identity provider shown while redirecting (resolved from the email domain in a real product). */
|
|
6
|
+
providerName: 'your identity provider',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Simple shape check before asking the server for the domain's identity provider. */
|
|
10
|
+
export const ssoEmailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Single sign-on',
|
|
5
|
+
family: 'Auth',
|
|
6
|
+
priority: 'P2',
|
|
7
|
+
status: 'beta',
|
|
8
|
+
description:
|
|
9
|
+
'Centred card SSO entry: work email lookup, then a "Redirecting to your identity provider" state with cancel and a password fallback.',
|
|
10
|
+
layout: 'AuthLayout',
|
|
11
|
+
blocks: [],
|
|
12
|
+
};
|