@djangocfg/layouts 2.1.423 → 2.1.425
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/package.json +17 -17
- package/src/layouts/AuthLayout/AuthLayout.tsx +8 -11
- package/src/layouts/AuthLayout/README.md +50 -18
- package/src/layouts/AuthLayout/components/shared/AuthConsent.tsx +46 -0
- package/src/layouts/AuthLayout/components/shared/index.ts +2 -2
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -25
- package/src/layouts/AuthLayout/context.tsx +0 -4
- package/src/layouts/AuthLayout/shells/AuthShell.tsx +10 -1
- package/src/layouts/AuthLayout/shells/FullSplitShell.tsx +73 -0
- package/src/layouts/AuthLayout/shells/types.ts +14 -4
- package/src/layouts/AuthLayout/styles/auth.css +62 -40
- package/src/layouts/AuthLayout/styles/fullsplit-shell.css +172 -0
- package/src/layouts/AuthLayout/styles/split-shell.css +74 -71
- package/src/layouts/AuthLayout/types.ts +6 -6
- package/src/testing/MockAuthFormProvider.tsx +0 -3
- package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +0 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.425",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -89,13 +89,13 @@
|
|
|
89
89
|
"check": "tsc --noEmit"
|
|
90
90
|
},
|
|
91
91
|
"peerDependencies": {
|
|
92
|
-
"@djangocfg/api": "^2.1.
|
|
93
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
94
|
-
"@djangocfg/debuger": "^2.1.
|
|
95
|
-
"@djangocfg/i18n": "^2.1.
|
|
96
|
-
"@djangocfg/monitor": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
98
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
92
|
+
"@djangocfg/api": "^2.1.425",
|
|
93
|
+
"@djangocfg/centrifugo": "^2.1.425",
|
|
94
|
+
"@djangocfg/debuger": "^2.1.425",
|
|
95
|
+
"@djangocfg/i18n": "^2.1.425",
|
|
96
|
+
"@djangocfg/monitor": "^2.1.425",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.425",
|
|
98
|
+
"@djangocfg/ui-nextjs": "^2.1.425",
|
|
99
99
|
"@hookform/resolvers": "^5.2.2",
|
|
100
100
|
"consola": "^3.4.2",
|
|
101
101
|
"lucide-react": "^0.545.0",
|
|
@@ -126,15 +126,15 @@
|
|
|
126
126
|
"uuid": "^11.1.0"
|
|
127
127
|
},
|
|
128
128
|
"devDependencies": {
|
|
129
|
-
"@djangocfg/api": "^2.1.
|
|
130
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
131
|
-
"@djangocfg/debuger": "^2.1.
|
|
132
|
-
"@djangocfg/i18n": "^2.1.
|
|
133
|
-
"@djangocfg/monitor": "^2.1.
|
|
134
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
135
|
-
"@djangocfg/ui-core": "^2.1.
|
|
136
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
137
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
129
|
+
"@djangocfg/api": "^2.1.425",
|
|
130
|
+
"@djangocfg/centrifugo": "^2.1.425",
|
|
131
|
+
"@djangocfg/debuger": "^2.1.425",
|
|
132
|
+
"@djangocfg/i18n": "^2.1.425",
|
|
133
|
+
"@djangocfg/monitor": "^2.1.425",
|
|
134
|
+
"@djangocfg/typescript-config": "^2.1.425",
|
|
135
|
+
"@djangocfg/ui-core": "^2.1.425",
|
|
136
|
+
"@djangocfg/ui-nextjs": "^2.1.425",
|
|
137
|
+
"@djangocfg/ui-tools": "^2.1.425",
|
|
138
138
|
"@types/node": "^25.2.3",
|
|
139
139
|
"@types/react": "^19.2.15",
|
|
140
140
|
"@types/react-dom": "^19.2.3",
|
|
@@ -17,7 +17,7 @@ import { useAppT } from '@djangocfg/i18n';
|
|
|
17
17
|
|
|
18
18
|
import { Suspense } from '../../components';
|
|
19
19
|
import { OAuthCallback } from './components/oauth';
|
|
20
|
-
import { IdentifierStep, OTPStep,
|
|
20
|
+
import { IdentifierStep, OTPStep, TwoFactorStep } from './components/steps';
|
|
21
21
|
import { AUTH } from './constants';
|
|
22
22
|
import { AuthFormProvider, useAuthFormContext } from './context';
|
|
23
23
|
import { AuthShell } from './shells';
|
|
@@ -25,6 +25,7 @@ import { AuthShell } from './shells';
|
|
|
25
25
|
import './styles/auth.css';
|
|
26
26
|
import './styles/centered-shell.css';
|
|
27
27
|
import './styles/split-shell.css';
|
|
28
|
+
import './styles/fullsplit-shell.css';
|
|
28
29
|
|
|
29
30
|
import type { AuthLayoutProps } from './types';
|
|
30
31
|
|
|
@@ -43,7 +44,8 @@ export const useAuthLayoutContext = (): AuthLayoutContextValue => useContext(Aut
|
|
|
43
44
|
|
|
44
45
|
export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
|
|
45
46
|
const {
|
|
46
|
-
variant = '
|
|
47
|
+
variant = 'fullsplit',
|
|
48
|
+
mediaSide = 'left',
|
|
47
49
|
background,
|
|
48
50
|
sidebar,
|
|
49
51
|
enableGithubAuth,
|
|
@@ -78,7 +80,7 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
|
|
|
78
80
|
{/* Full-screen success overlay */}
|
|
79
81
|
<AuthSuccessOverlay />
|
|
80
82
|
|
|
81
|
-
<AuthShell variant={variant} background={background} sidebar={sidebar} className={className}>
|
|
83
|
+
<AuthShell variant={variant} mediaSide={mediaSide} background={background} sidebar={sidebar} className={className}>
|
|
82
84
|
{/* Handle OAuth callback when GitHub auth is enabled */}
|
|
83
85
|
{oauthCallback}
|
|
84
86
|
|
|
@@ -99,7 +101,7 @@ const AuthHeaderSlot: React.FC<{ children?: React.ReactNode }> = memo(({ childre
|
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
const AuthContent: React.FC = memo(() => {
|
|
102
|
-
const { step
|
|
104
|
+
const { step } = useAuthFormContext();
|
|
103
105
|
|
|
104
106
|
switch (step) {
|
|
105
107
|
case 'identifier':
|
|
@@ -107,14 +109,9 @@ const AuthContent: React.FC = memo(() => {
|
|
|
107
109
|
case 'otp':
|
|
108
110
|
return <OTPStep />;
|
|
109
111
|
case '2fa':
|
|
112
|
+
// Sign-in verifies an *existing* 2FA challenge only. Setting up 2FA is
|
|
113
|
+
// owned by ProfileLayout, so the flow never renders the setup step.
|
|
110
114
|
return <TwoFactorStep />;
|
|
111
|
-
case '2fa-setup':
|
|
112
|
-
return (
|
|
113
|
-
<SetupStep
|
|
114
|
-
onComplete={() => setStep('success')}
|
|
115
|
-
onSkip={() => setStep('success')}
|
|
116
|
-
/>
|
|
117
|
-
);
|
|
118
115
|
case 'success':
|
|
119
116
|
// Success is rendered as full-screen overlay, return null here
|
|
120
117
|
return null;
|
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
# AuthLayout
|
|
2
2
|
|
|
3
|
-
Shell-based authentication layout with
|
|
4
|
-
- `
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Shell-based authentication layout with three visual variants:
|
|
4
|
+
- `fullsplit` **(default)** — **full-bleed 50/50** (the large-SaaS look —
|
|
5
|
+
Vercel / Linear / IBM). Desktop: edge-to-edge — `background` image on one side
|
|
6
|
+
(left by default, see `mediaSide`) with the `sidebar` (quote/brand) overlaid
|
|
7
|
+
and a soft bottom-up shadow scrim; form on a solid panel on the other side.
|
|
8
|
+
The photo fades in once loaded and gets a slow Ken Burns zoom. Mobile: the
|
|
9
|
+
image half drops away, leaving a plain centered form.
|
|
10
|
+
- `centered` — Apple HIG-style, frameless, centered, glow background.
|
|
11
|
+
- `split` — on desktop a two-column **frosted-glass** card (form + sidebar)
|
|
12
|
+
floating over the `background` image; on mobile it collapses to a plain,
|
|
13
|
+
frameless centered form (no image, no glass, no sidebar).
|
|
14
|
+
|
|
15
|
+
The logo (when `logoUrl` is set) always renders above the form across all
|
|
16
|
+
variants; the `sidebar` slot should carry the quote/illustration, not a second
|
|
17
|
+
brand mark.
|
|
18
|
+
|
|
19
|
+
Supports: email OTP, phone OTP, GitHub OAuth, and **verifying** an existing 2FA
|
|
20
|
+
challenge (TOTP + backup codes).
|
|
21
|
+
|
|
22
|
+
**Consent, not a checkbox.** There is no opt-in terms checkbox — continuing *is*
|
|
23
|
+
the consent. When `termsUrl`/`privacyUrl` are set, a passive line ("By continuing
|
|
24
|
+
you agree to the Terms and Privacy Policy") renders under the submit button with
|
|
25
|
+
links that open in a new tab. The copy is intentionally hardcoded English (legal
|
|
26
|
+
document names stay in English across locales) — see `shared/AuthConsent`.
|
|
27
|
+
|
|
28
|
+
> **2FA setup is not part of the sign-in flow.** Authentication only verifies
|
|
29
|
+
> identity — it checks the OTP and, if the account already has 2FA, prompts for
|
|
30
|
+
> the TOTP/backup code. *Configuring* 2FA (QR + backup codes) lives in
|
|
31
|
+
> **ProfileLayout** (`TwoFactorSection`). The sign-in flow never shows a setup
|
|
32
|
+
> prompt.
|
|
8
33
|
|
|
9
34
|
## Usage
|
|
10
35
|
|
|
11
|
-
### Centered variant
|
|
36
|
+
### Centered variant
|
|
12
37
|
|
|
13
38
|
```tsx
|
|
14
39
|
import { AuthLayout } from '@djangocfg/layouts';
|
|
@@ -60,17 +85,17 @@ import { AuthLayout } from '@djangocfg/layouts';
|
|
|
60
85
|
|
|
61
86
|
| Prop | Type | Default | Description |
|
|
62
87
|
|---|---|---|---|
|
|
63
|
-
| `variant` | `'centered' \| 'split'` | `'
|
|
88
|
+
| `variant` | `'centered' \| 'split' \| 'fullsplit'` | `'fullsplit'` | Shell layout variant |
|
|
89
|
+
| `mediaSide` | `'left' \| 'right'` | `'left'` | Which side the image half sits on (`fullsplit` only) |
|
|
64
90
|
| `background` | `AuthBackgroundConfig` | — | Background image/gradient/overlay/blur |
|
|
65
91
|
| `sidebar` | `ReactNode` | — | Right column content (split variant only) |
|
|
66
92
|
| `sourceUrl` | `string` | — | App URL for analytics/tracking |
|
|
67
93
|
| `redirectUrl` | `string` | `/dashboard` | Where to redirect after auth |
|
|
68
94
|
| `enableGithubAuth` | `boolean` | `false` | Show GitHub OAuth button |
|
|
69
95
|
| `enablePhoneAuth` | `boolean` | `false` | Allow phone number input |
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `privacyUrl` | `string` | — | Privacy policy link |
|
|
96
|
+
| `logoUrl` | `string` | — | Logo shown above the form (centered only) and on the success screen |
|
|
97
|
+
| `termsUrl` | `string` | — | Terms link — rendered in the passive consent line (opens in a new tab) |
|
|
98
|
+
| `privacyUrl` | `string` | — | Privacy link — rendered in the passive consent line (opens in a new tab) |
|
|
74
99
|
| `supportUrl` | `string` | — | Support page link |
|
|
75
100
|
| `className` | `string` | — | Extra class on root element |
|
|
76
101
|
| `children` | `ReactNode` | — | Custom header (identifier step only) |
|
|
@@ -97,16 +122,21 @@ import { AuthLayout } from '@djangocfg/layouts';
|
|
|
97
122
|
|
|
98
123
|
## Auth Steps
|
|
99
124
|
|
|
125
|
+
The sign-in flow walks these steps:
|
|
126
|
+
|
|
100
127
|
| Step | Description |
|
|
101
128
|
|---|---|
|
|
102
129
|
| `identifier` | Email or phone input |
|
|
103
130
|
| `otp` | 4-digit email OTP code entry |
|
|
104
|
-
| `2fa` | TOTP or backup code verification |
|
|
105
|
-
| `2fa-setup` | QR code + backup codes setup |
|
|
131
|
+
| `2fa` | TOTP or backup code verification (only if the account already has 2FA) |
|
|
106
132
|
| `success` | Full-screen success overlay → redirect |
|
|
107
133
|
|
|
108
134
|
`children` (custom header) are only rendered on the `identifier` step — hidden on all others.
|
|
109
135
|
|
|
136
|
+
> The `AuthStep` union also includes `'2fa-setup'`, but the sign-in flow never
|
|
137
|
+
> transitions to it. That value backs the standalone setup UI (`SetupStep` /
|
|
138
|
+
> `SetupStepStandalone`) which **ProfileLayout** reuses for configuring 2FA.
|
|
139
|
+
|
|
110
140
|
## Architecture
|
|
111
141
|
|
|
112
142
|
AuthLayout is built on a **shell pattern** (inspired by PublicLayout's navbar/footer slots):
|
|
@@ -116,15 +146,17 @@ AuthLayout/
|
|
|
116
146
|
├── shells/
|
|
117
147
|
│ ├── AuthShell.tsx — Orchestrator: loads bg image, dispatches variant
|
|
118
148
|
│ ├── CenteredShell.tsx — Apple-style frameless layout
|
|
119
|
-
│ ├── SplitShell.tsx —
|
|
149
|
+
│ ├── SplitShell.tsx — Floating frosted-glass card layout
|
|
150
|
+
│ ├── FullSplitShell.tsx — Full-bleed 50/50 layout
|
|
120
151
|
│ ├── context.tsx — Shell context (variant, hasSidebar)
|
|
121
152
|
│ └── types.ts — AuthShellVariant, AuthBackgroundConfig
|
|
122
153
|
├── components/steps/ — Auth step components (IdentifierStep, OTPStep, ...)
|
|
123
|
-
├── components/shared/ — Reusable UI primitives (AuthButton,
|
|
154
|
+
├── components/shared/ — Reusable UI primitives (AuthButton, AuthConsent, ...)
|
|
124
155
|
├── styles/
|
|
125
|
-
│ ├── auth.css
|
|
126
|
-
│ ├── centered-shell.css
|
|
127
|
-
│
|
|
156
|
+
│ ├── auth.css — Shared form/button/input styles
|
|
157
|
+
│ ├── centered-shell.css — Centered variant layout
|
|
158
|
+
│ ├── split-shell.css — Split variant layout
|
|
159
|
+
│ └── fullsplit-shell.css — Full-bleed split variant layout
|
|
128
160
|
```
|
|
129
161
|
|
|
130
162
|
Adding a new variant:
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { memo } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface AuthConsentProps {
|
|
6
|
+
termsUrl?: string;
|
|
7
|
+
privacyUrl?: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AuthConsent — passive consent line shown under the submit button.
|
|
13
|
+
*
|
|
14
|
+
* Replaces the old opt-in checkbox: continuing *is* the consent, so this only
|
|
15
|
+
* informs and links out. Shared by both the Centered and Split forms.
|
|
16
|
+
*
|
|
17
|
+
* The legal copy is intentionally hardcoded English and NOT translated:
|
|
18
|
+
* "Terms"/"Privacy Policy" are proper names of legal documents that stay in
|
|
19
|
+
* English across locales, and the one-line "By continuing…" boilerplate isn't
|
|
20
|
+
* worth a 17-locale i18n key. Links always open in a new tab.
|
|
21
|
+
*
|
|
22
|
+
* Renders nothing when neither legal URL is provided (no empty line).
|
|
23
|
+
*
|
|
24
|
+
* Memoised: re-renders only when the URL props or className change.
|
|
25
|
+
*/
|
|
26
|
+
function AuthConsentRaw({ termsUrl, privacyUrl, className = '' }: AuthConsentProps) {
|
|
27
|
+
if (!termsUrl && !privacyUrl) return null;
|
|
28
|
+
|
|
29
|
+
const link = (href: string, label: string) => (
|
|
30
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className="auth-consent-link">
|
|
31
|
+
{label}
|
|
32
|
+
</a>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<p className={`auth-consent ${className}`}>
|
|
37
|
+
By continuing you agree to the{' '}
|
|
38
|
+
{termsUrl && link(termsUrl, 'Terms')}
|
|
39
|
+
{termsUrl && privacyUrl && ' and '}
|
|
40
|
+
{privacyUrl && link(privacyUrl, 'Privacy Policy')}
|
|
41
|
+
.
|
|
42
|
+
</p>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const AuthConsent = memo(AuthConsentRaw);
|
|
@@ -6,7 +6,7 @@ export { AuthError } from './AuthError';
|
|
|
6
6
|
export { AuthButton } from './AuthButton';
|
|
7
7
|
export { AuthLink } from './AuthLink';
|
|
8
8
|
export { AuthOTPInput } from './AuthOTPInput';
|
|
9
|
-
export {
|
|
9
|
+
export { AuthConsent } from './AuthConsent';
|
|
10
10
|
|
|
11
11
|
export type { AuthContainerProps } from './AuthContainer';
|
|
12
12
|
export type { AuthHeaderProps } from './AuthHeader';
|
|
@@ -16,4 +16,4 @@ export type { AuthErrorProps } from './AuthError';
|
|
|
16
16
|
export type { AuthButtonProps } from './AuthButton';
|
|
17
17
|
export type { AuthLinkProps } from './AuthLink';
|
|
18
18
|
export type { AuthOTPInputProps } from './AuthOTPInput';
|
|
19
|
-
export type {
|
|
19
|
+
export type { AuthConsentProps } from './AuthConsent';
|
|
@@ -11,21 +11,20 @@ import { useAuthLayoutContext } from '../../AuthLayout';
|
|
|
11
11
|
import { useAuthFormContext } from '../../context';
|
|
12
12
|
import {
|
|
13
13
|
AuthButton,
|
|
14
|
+
AuthConsent,
|
|
14
15
|
AuthContainer,
|
|
15
16
|
AuthDivider,
|
|
16
17
|
AuthError,
|
|
17
|
-
AuthFooter,
|
|
18
18
|
AuthHeader,
|
|
19
|
-
TermsCheckbox,
|
|
20
19
|
} from '../shared';
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* IdentifierStep - Apple-style email input step.
|
|
24
23
|
*
|
|
25
24
|
* Clean, minimal design with:
|
|
26
|
-
* - Optional logo
|
|
25
|
+
* - Optional logo (hidden in the split variant — sidebar carries the brand)
|
|
27
26
|
* - Single email input field
|
|
28
|
-
* -
|
|
27
|
+
* - Passive consent line (continuing = consent; no opt-in checkbox)
|
|
29
28
|
* - OAuth options
|
|
30
29
|
*
|
|
31
30
|
* Memoised: this component has no props; it reads everything from
|
|
@@ -40,18 +39,15 @@ function IdentifierStepRaw() {
|
|
|
40
39
|
const {
|
|
41
40
|
identifier,
|
|
42
41
|
isLoading,
|
|
43
|
-
acceptedTerms,
|
|
44
42
|
error,
|
|
45
43
|
isRateLimited,
|
|
46
44
|
rateLimitLabel,
|
|
47
45
|
logoUrl,
|
|
48
46
|
termsUrl,
|
|
49
47
|
privacyUrl,
|
|
50
|
-
supportUrl,
|
|
51
48
|
enableGithubAuth,
|
|
52
49
|
sourceUrl,
|
|
53
50
|
setIdentifier,
|
|
54
|
-
setAcceptedTerms,
|
|
55
51
|
setError,
|
|
56
52
|
handleIdentifierSubmit,
|
|
57
53
|
} = useAuthFormContext();
|
|
@@ -78,8 +74,6 @@ function IdentifierStepRaw() {
|
|
|
78
74
|
onError: setError,
|
|
79
75
|
});
|
|
80
76
|
|
|
81
|
-
const hasTerms = Boolean(termsUrl || privacyUrl);
|
|
82
|
-
|
|
83
77
|
return (
|
|
84
78
|
<AuthContainer step="identifier">
|
|
85
79
|
{!hideHeader && <AuthHeader logo={logoUrl} title={content.title} subtitle={content.subtitle.email} />}
|
|
@@ -87,32 +81,31 @@ function IdentifierStepRaw() {
|
|
|
87
81
|
<form onSubmit={handleIdentifierSubmit} className="auth-form-group">
|
|
88
82
|
<Input
|
|
89
83
|
type="email"
|
|
84
|
+
name="email"
|
|
90
85
|
value={identifier}
|
|
91
86
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
92
87
|
placeholder={content.placeholder.email}
|
|
93
88
|
disabled={isLoading}
|
|
94
89
|
required
|
|
95
90
|
autoFocus
|
|
96
|
-
autoComplete="
|
|
91
|
+
autoComplete="email"
|
|
92
|
+
inputMode="email"
|
|
93
|
+
autoCapitalize="off"
|
|
94
|
+
spellCheck={false}
|
|
97
95
|
className="auth-input"
|
|
98
96
|
/>
|
|
99
97
|
|
|
100
|
-
<TermsCheckbox
|
|
101
|
-
checked={acceptedTerms}
|
|
102
|
-
onChange={setAcceptedTerms}
|
|
103
|
-
termsUrl={termsUrl}
|
|
104
|
-
privacyUrl={privacyUrl}
|
|
105
|
-
disabled={isLoading}
|
|
106
|
-
/>
|
|
107
|
-
|
|
108
98
|
<AuthError message={error} />
|
|
109
99
|
|
|
110
100
|
<AuthButton
|
|
111
101
|
loading={isLoading}
|
|
112
|
-
disabled={!identifier ||
|
|
102
|
+
disabled={!identifier || isRateLimited}
|
|
113
103
|
>
|
|
114
104
|
{isRateLimited ? `${content.button} (${rateLimitLabel})` : content.button}
|
|
115
105
|
</AuthButton>
|
|
106
|
+
|
|
107
|
+
{/* Continuing implies consent — passive line, no opt-in checkbox. */}
|
|
108
|
+
<AuthConsent termsUrl={termsUrl} privacyUrl={privacyUrl} />
|
|
116
109
|
</form>
|
|
117
110
|
|
|
118
111
|
{enableGithubAuth && (
|
|
@@ -129,12 +122,6 @@ function IdentifierStepRaw() {
|
|
|
129
122
|
</AuthButton>
|
|
130
123
|
</>
|
|
131
124
|
)}
|
|
132
|
-
|
|
133
|
-
<AuthFooter
|
|
134
|
-
termsUrl={termsUrl}
|
|
135
|
-
privacyUrl={privacyUrl}
|
|
136
|
-
supportUrl={supportUrl}
|
|
137
|
-
/>
|
|
138
125
|
</AuthContainer>
|
|
139
126
|
);
|
|
140
127
|
}
|
|
@@ -25,7 +25,6 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
25
25
|
termsUrl,
|
|
26
26
|
privacyUrl,
|
|
27
27
|
enableGithubAuth = false,
|
|
28
|
-
enable2FASetup = true,
|
|
29
28
|
logoUrl,
|
|
30
29
|
redirectUrl,
|
|
31
30
|
onIdentifierSuccess,
|
|
@@ -45,7 +44,6 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
45
44
|
sourceUrl,
|
|
46
45
|
redirectUrl,
|
|
47
46
|
requireTermsAcceptance,
|
|
48
|
-
enable2FASetup,
|
|
49
47
|
});
|
|
50
48
|
|
|
51
49
|
const value: AuthFormContextType = useMemo(
|
|
@@ -57,7 +55,6 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
57
55
|
termsUrl,
|
|
58
56
|
privacyUrl,
|
|
59
57
|
enableGithubAuth,
|
|
60
|
-
enable2FASetup,
|
|
61
58
|
logoUrl,
|
|
62
59
|
redirectUrl,
|
|
63
60
|
}),
|
|
@@ -69,7 +66,6 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
69
66
|
termsUrl,
|
|
70
67
|
privacyUrl,
|
|
71
68
|
enableGithubAuth,
|
|
72
|
-
enable2FASetup,
|
|
73
69
|
logoUrl,
|
|
74
70
|
redirectUrl,
|
|
75
71
|
]
|
|
@@ -6,6 +6,7 @@ import { useImageLoader } from '@djangocfg/ui-core/hooks';
|
|
|
6
6
|
|
|
7
7
|
import { AuthShellProvider } from './context';
|
|
8
8
|
import { CenteredShell } from './CenteredShell';
|
|
9
|
+
import { FullSplitShell } from './FullSplitShell';
|
|
9
10
|
import { SplitShell } from './SplitShell';
|
|
10
11
|
import type { AuthShellProps } from './types';
|
|
11
12
|
|
|
@@ -27,13 +28,18 @@ import type { AuthShellProps } from './types';
|
|
|
27
28
|
function AuthShellRaw({
|
|
28
29
|
variant,
|
|
29
30
|
children,
|
|
31
|
+
mediaSide = 'left',
|
|
30
32
|
background,
|
|
31
33
|
sidebar,
|
|
32
34
|
className,
|
|
33
35
|
}: AuthShellProps) {
|
|
34
36
|
const { isLoaded: bgLoaded, hasError: bgError } = useImageLoader(background?.imageUrl);
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
// Build the image style as soon as a URL is provided (the image is
|
|
39
|
+
// pre-warmed by useImageLoader, so painting it doesn't flash). `bgLoaded`
|
|
40
|
+
// is passed through separately so the shell can fade the layer in once the
|
|
41
|
+
// download actually completes — a quiet preloader, no spinner.
|
|
42
|
+
const hasBgImage = Boolean(background?.imageUrl) && !bgError;
|
|
37
43
|
|
|
38
44
|
const bgStyle = useMemo(() => {
|
|
39
45
|
if (hasBgImage && background?.imageUrl) {
|
|
@@ -71,12 +77,15 @@ function AuthShellRaw({
|
|
|
71
77
|
bgStyle,
|
|
72
78
|
overlayStyle,
|
|
73
79
|
blurValue,
|
|
80
|
+
bgLoaded,
|
|
74
81
|
};
|
|
75
82
|
|
|
76
83
|
return (
|
|
77
84
|
<AuthShellProvider value={shellContext}>
|
|
78
85
|
{variant === 'split' ? (
|
|
79
86
|
<SplitShell {...commonShellProps} sidebar={sidebar} />
|
|
87
|
+
) : variant === 'fullsplit' ? (
|
|
88
|
+
<FullSplitShell {...commonShellProps} mediaSide={mediaSide} sidebar={sidebar} />
|
|
80
89
|
) : (
|
|
81
90
|
<CenteredShell {...commonShellProps} />
|
|
82
91
|
)}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { memo } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ShellRenderProps } from './types';
|
|
6
|
+
|
|
7
|
+
interface FullSplitShellProps extends ShellRenderProps {
|
|
8
|
+
sidebar?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* FullSplitShell — full-bleed 50/50 auth layout (the large-SaaS look).
|
|
13
|
+
*
|
|
14
|
+
* Desktop (>= lg): edge-to-edge. Left half is the background image with an
|
|
15
|
+
* overlay and the `sidebar` content (brand + quote) laid over it; right half
|
|
16
|
+
* is a solid `--background` panel with the centered form.
|
|
17
|
+
* Mobile (< lg): the image half is dropped entirely — just the plain centered
|
|
18
|
+
* form on `--background`, like an ordinary sign-in screen.
|
|
19
|
+
*
|
|
20
|
+
* No floating card, no glass: the two halves meet edge-to-edge, which reads as
|
|
21
|
+
* a considered product layout rather than a panel hovering in space.
|
|
22
|
+
*
|
|
23
|
+
* Memoised: re-renders only when bgStyle, overlayStyle, blurValue, sidebar or
|
|
24
|
+
* children references change.
|
|
25
|
+
*/
|
|
26
|
+
function FullSplitShellRaw({
|
|
27
|
+
children,
|
|
28
|
+
className,
|
|
29
|
+
bgStyle,
|
|
30
|
+
overlayStyle,
|
|
31
|
+
blurValue,
|
|
32
|
+
mediaSide = 'left',
|
|
33
|
+
bgLoaded = false,
|
|
34
|
+
sidebar,
|
|
35
|
+
}: FullSplitShellProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={`auth-shell-fullsplit ${className || ''}`}
|
|
39
|
+
data-media-side={mediaSide}
|
|
40
|
+
>
|
|
41
|
+
{/* Media half — `data-loaded` drives the image fade-in. */}
|
|
42
|
+
<div className="auth-shell-fullsplit__media">
|
|
43
|
+
{bgStyle && (
|
|
44
|
+
<div
|
|
45
|
+
className="auth-shell-fullsplit__bg"
|
|
46
|
+
style={bgStyle}
|
|
47
|
+
data-loaded={bgLoaded}
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
{overlayStyle && (
|
|
51
|
+
<div
|
|
52
|
+
className="auth-shell-fullsplit__overlay"
|
|
53
|
+
style={{
|
|
54
|
+
...overlayStyle,
|
|
55
|
+
backdropFilter: blurValue ? `blur(${blurValue})` : undefined,
|
|
56
|
+
WebkitBackdropFilter: blurValue ? `blur(${blurValue})` : undefined,
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
)}
|
|
60
|
+
{sidebar && (
|
|
61
|
+
<div className="auth-shell-fullsplit__sidebar">{sidebar}</div>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{/* Right: solid panel with the centered form */}
|
|
66
|
+
<div className="auth-shell-fullsplit__form">
|
|
67
|
+
<div className="auth-shell-fullsplit__form-inner">{children}</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const FullSplitShell = memo(FullSplitShellRaw);
|
|
@@ -12,11 +12,15 @@ import type { CSSProperties, ReactNode } from 'react';
|
|
|
12
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* - `centered`
|
|
16
|
-
* - `split`
|
|
17
|
-
*
|
|
15
|
+
* - `centered` — Apple-style frameless, centered, glow background (default).
|
|
16
|
+
* - `split` — Floating two-column card: form left, sidebar right on
|
|
17
|
+
* desktop; single-column centered on mobile.
|
|
18
|
+
* - `fullsplit` — Full-bleed 50/50: edge-to-edge background image on the left
|
|
19
|
+
* (brand + quote overlaid), form on a solid panel on the right.
|
|
20
|
+
* Mobile collapses to a plain centered form. The large-SaaS
|
|
21
|
+
* look (Vercel / Linear / IBM).
|
|
18
22
|
*/
|
|
19
|
-
export type AuthShellVariant = 'centered' | 'split';
|
|
23
|
+
export type AuthShellVariant = 'centered' | 'split' | 'fullsplit';
|
|
20
24
|
|
|
21
25
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
26
|
// Background Configuration
|
|
@@ -40,6 +44,8 @@ export interface AuthBackgroundConfig {
|
|
|
40
44
|
export interface AuthShellProps {
|
|
41
45
|
variant: AuthShellVariant;
|
|
42
46
|
children: ReactNode;
|
|
47
|
+
/** Which side the media half sits on (fullsplit only). @default 'left' */
|
|
48
|
+
mediaSide?: 'left' | 'right';
|
|
43
49
|
/** Background configuration — image, gradient, overlay, blur. */
|
|
44
50
|
background?: AuthBackgroundConfig;
|
|
45
51
|
/** Slot for the right column in split variant (testimonial, branding, etc.). */
|
|
@@ -54,6 +60,10 @@ export interface ShellRenderProps {
|
|
|
54
60
|
bgStyle?: CSSProperties;
|
|
55
61
|
overlayStyle?: CSSProperties;
|
|
56
62
|
blurValue?: string;
|
|
63
|
+
/** Which side the media half sits on (fullsplit only). @default 'left' */
|
|
64
|
+
mediaSide?: 'left' | 'right';
|
|
65
|
+
/** True once the background image has finished loading (drives fade-in). */
|
|
66
|
+
bgLoaded?: boolean;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -91,10 +91,10 @@
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
.auth-title {
|
|
94
|
-
font-size: 1.
|
|
95
|
-
font-weight:
|
|
96
|
-
line-height: 1.
|
|
97
|
-
letter-spacing: -0.
|
|
94
|
+
font-size: 1.5rem;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
line-height: 1.2;
|
|
97
|
+
letter-spacing: -0.02em;
|
|
98
98
|
color: var(--foreground);
|
|
99
99
|
margin: 0;
|
|
100
100
|
}
|
|
@@ -124,8 +124,14 @@
|
|
|
124
124
|
height: 3rem;
|
|
125
125
|
padding: 0 0.875rem;
|
|
126
126
|
font-size: 0.9375rem;
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
/* The field is defined by its border, not a fill that fights its container.
|
|
128
|
+
`--input` alone matched `--border` (~90% on light) and dissolved; a fill
|
|
129
|
+
of `--card` would instead dissolve inside the Split card (also --card).
|
|
130
|
+
So: a recessed `--background` fill + a deliberately visible border that
|
|
131
|
+
bounds the field on both the frameless Centered shell and the --card
|
|
132
|
+
Split panel. */
|
|
133
|
+
background: var(--background);
|
|
134
|
+
border: 1px solid color-mix(in oklab, var(--border) 70%, var(--foreground));
|
|
129
135
|
border-radius: var(--auth-radius-sm);
|
|
130
136
|
color: var(--foreground);
|
|
131
137
|
transition:
|
|
@@ -133,6 +139,10 @@
|
|
|
133
139
|
box-shadow 0.18s var(--spring-snappy);
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
.auth-input:hover:not(:focus):not(:disabled) {
|
|
143
|
+
border-color: color-mix(in oklab, var(--border) 60%, var(--foreground));
|
|
144
|
+
}
|
|
145
|
+
|
|
136
146
|
.auth-input:focus {
|
|
137
147
|
outline: none;
|
|
138
148
|
border-color: var(--ring);
|
|
@@ -153,8 +163,8 @@
|
|
|
153
163
|
height: 3rem;
|
|
154
164
|
padding: 0 0.875rem;
|
|
155
165
|
font-size: 0.9375rem;
|
|
156
|
-
background: var(--
|
|
157
|
-
border: 1px solid var(--border);
|
|
166
|
+
background: var(--background);
|
|
167
|
+
border: 1px solid color-mix(in oklab, var(--border) 70%, var(--foreground));
|
|
158
168
|
border-radius: var(--auth-radius-sm);
|
|
159
169
|
color: var(--foreground);
|
|
160
170
|
transition:
|
|
@@ -195,7 +205,6 @@
|
|
|
195
205
|
}
|
|
196
206
|
|
|
197
207
|
.auth-button:disabled {
|
|
198
|
-
opacity: 0.45;
|
|
199
208
|
cursor: not-allowed;
|
|
200
209
|
}
|
|
201
210
|
|
|
@@ -204,10 +213,30 @@
|
|
|
204
213
|
color: var(--primary-foreground);
|
|
205
214
|
}
|
|
206
215
|
|
|
216
|
+
/* Disabled primary: read as genuinely inactive — a flat neutral fill with
|
|
217
|
+
muted text — rather than a translucent version of the active blue, which
|
|
218
|
+
the old `opacity: .45` produced (it still looked clickable). */
|
|
219
|
+
.auth-button-primary:disabled {
|
|
220
|
+
background: var(--muted);
|
|
221
|
+
color: var(--muted-foreground);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* GitHub / OAuth: a quiet outline button so the email path (primary) stays
|
|
225
|
+
the single loudest action. `--secondary` in light is near-black, which made
|
|
226
|
+
the secondary path shout louder than the primary one — HIG wants one accent. */
|
|
207
227
|
.auth-button-secondary {
|
|
208
|
-
background:
|
|
209
|
-
color: var(--
|
|
210
|
-
border:
|
|
228
|
+
background: transparent;
|
|
229
|
+
color: var(--foreground);
|
|
230
|
+
border: 1px solid var(--border);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.auth-button-secondary:hover:not(:disabled) {
|
|
234
|
+
background: var(--accent);
|
|
235
|
+
opacity: 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.auth-button-secondary:disabled {
|
|
239
|
+
opacity: 0.45;
|
|
211
240
|
}
|
|
212
241
|
|
|
213
242
|
.auth-button:hover:not(:disabled) {
|
|
@@ -344,30 +373,26 @@
|
|
|
344
373
|
opacity: 0.25;
|
|
345
374
|
}
|
|
346
375
|
|
|
347
|
-
/* =====
|
|
376
|
+
/* ===== CONSENT (passive line under the submit button) ===== */
|
|
348
377
|
|
|
349
|
-
.auth-
|
|
350
|
-
|
|
351
|
-
align
|
|
352
|
-
|
|
353
|
-
font-size: 0.8125rem;
|
|
378
|
+
.auth-consent {
|
|
379
|
+
margin: 0;
|
|
380
|
+
text-align: center;
|
|
381
|
+
font-size: 0.75rem;
|
|
354
382
|
line-height: 1.5;
|
|
355
383
|
color: var(--muted-foreground);
|
|
356
384
|
}
|
|
357
385
|
|
|
358
|
-
.auth-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
color: var(--primary);
|
|
365
|
-
text-decoration: none;
|
|
366
|
-
transition: opacity 0.15s;
|
|
386
|
+
.auth-consent-link {
|
|
387
|
+
color: var(--muted-foreground);
|
|
388
|
+
text-decoration: underline;
|
|
389
|
+
text-underline-offset: 2px;
|
|
390
|
+
text-decoration-color: color-mix(in oklab, var(--muted-foreground) 45%, transparent);
|
|
391
|
+
transition: color 0.15s;
|
|
367
392
|
}
|
|
368
393
|
|
|
369
|
-
.auth-
|
|
370
|
-
|
|
394
|
+
.auth-consent-link:hover {
|
|
395
|
+
color: var(--foreground);
|
|
371
396
|
}
|
|
372
397
|
|
|
373
398
|
/* ===== REMEMBER ME ===== */
|
|
@@ -463,8 +488,8 @@
|
|
|
463
488
|
font-weight: 600;
|
|
464
489
|
text-align: center;
|
|
465
490
|
letter-spacing: -0.01em;
|
|
466
|
-
background: var(--
|
|
467
|
-
border: 1px solid var(--border);
|
|
491
|
+
background: var(--background);
|
|
492
|
+
border: 1px solid color-mix(in oklab, var(--border) 70%, var(--foreground));
|
|
468
493
|
border-radius: var(--auth-radius-sm);
|
|
469
494
|
color: var(--foreground);
|
|
470
495
|
transition:
|
|
@@ -654,20 +679,17 @@
|
|
|
654
679
|
|
|
655
680
|
/* ===== DEV NOTICE ===== */
|
|
656
681
|
|
|
682
|
+
/* Uses the shared warning surface tokens (defined for both themes) instead of
|
|
683
|
+
hardcoded amber — keeps the dev banner consistent with the rest of the
|
|
684
|
+
system and drops the manual `.dark` override. */
|
|
657
685
|
.auth-dev-notice {
|
|
658
686
|
padding: 0.5rem 0.75rem;
|
|
659
687
|
font-size: 0.75rem;
|
|
660
688
|
text-align: center;
|
|
661
|
-
color:
|
|
662
|
-
background:
|
|
689
|
+
color: var(--warning-foreground);
|
|
690
|
+
background: var(--warning-background);
|
|
663
691
|
border-radius: var(--auth-radius-xs);
|
|
664
|
-
border: 1px solid
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
.dark .auth-dev-notice {
|
|
668
|
-
color: hsl(38 92% 65%);
|
|
669
|
-
background: hsl(38 92% 15%);
|
|
670
|
-
border-color: hsl(38 92% 30%);
|
|
692
|
+
border: 1px solid var(--warning-border);
|
|
671
693
|
}
|
|
672
694
|
|
|
673
695
|
/* ===== INSTRUCTION ===== */
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-Split Shell Styles
|
|
3
|
+
*
|
|
4
|
+
* Full-bleed 50/50 auth layout (Vercel / Linear / IBM style).
|
|
5
|
+
* Desktop (>= lg): edge-to-edge — image half (left) + form panel (right).
|
|
6
|
+
* Mobile (< lg): image half hidden; plain centered form on --background.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.auth-shell-fullsplit {
|
|
10
|
+
position: relative;
|
|
11
|
+
min-height: 100vh;
|
|
12
|
+
min-height: 100dvh;
|
|
13
|
+
display: flex;
|
|
14
|
+
background: var(--background);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* ── Media half (left) — hidden on mobile ──────────────────────────────── */
|
|
18
|
+
.auth-shell-fullsplit__media {
|
|
19
|
+
display: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.auth-shell-fullsplit__bg {
|
|
23
|
+
position: absolute;
|
|
24
|
+
inset: 0;
|
|
25
|
+
z-index: 0;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
/* Quiet preloader: the layer starts transparent and fades in only once the
|
|
28
|
+
image has actually downloaded (data-loaded="true"). No spinner, and the
|
|
29
|
+
form never waits on this — it renders immediately underneath. */
|
|
30
|
+
opacity: 0;
|
|
31
|
+
transition: opacity 0.6s var(--spring-smooth, ease-out);
|
|
32
|
+
/* Ken Burns — a slow, infinite zoom + drift that makes the hero photo
|
|
33
|
+
quietly "breathe". `alternate` reverses each cycle so it eases back
|
|
34
|
+
instead of hard-cutting to the start. Kept very slow (28s) and subtle
|
|
35
|
+
(max 1.08×) so it reads as life, not motion. The media half is
|
|
36
|
+
overflow:hidden, so the scaled-up image never reveals its edges. */
|
|
37
|
+
transform-origin: center;
|
|
38
|
+
animation: authKenBurns 28s ease-in-out infinite alternate;
|
|
39
|
+
will-change: transform, opacity;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.auth-shell-fullsplit__bg[data-loaded="true"] {
|
|
43
|
+
opacity: 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* While the photo loads, the media half shows a calm base surface (not a
|
|
47
|
+
black void) so the split reads as intentional even on a slow connection. */
|
|
48
|
+
.auth-shell-fullsplit__media {
|
|
49
|
+
background: var(--muted);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@keyframes authKenBurns {
|
|
53
|
+
from {
|
|
54
|
+
transform: scale(1) translate3d(0, 0, 0);
|
|
55
|
+
}
|
|
56
|
+
to {
|
|
57
|
+
transform: scale(1.08) translate3d(-1.5%, -1%, 0);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Honour reduced-motion: hold the photo still. */
|
|
62
|
+
@media (prefers-reduced-motion: reduce) {
|
|
63
|
+
.auth-shell-fullsplit__bg {
|
|
64
|
+
animation: none;
|
|
65
|
+
transform: none;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.auth-shell-fullsplit__overlay {
|
|
70
|
+
position: absolute;
|
|
71
|
+
inset: 0;
|
|
72
|
+
z-index: 1;
|
|
73
|
+
pointer-events: none;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Sidebar content (brand top, quote bottom) over the photo. */
|
|
77
|
+
.auth-shell-fullsplit__sidebar {
|
|
78
|
+
position: relative;
|
|
79
|
+
z-index: 2;
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
justify-content: space-between;
|
|
83
|
+
height: 100%;
|
|
84
|
+
padding: 3rem;
|
|
85
|
+
color: #fff;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ── Form half (right) ─────────────────────────────────────────────────── */
|
|
89
|
+
.auth-shell-fullsplit__form {
|
|
90
|
+
flex: 1;
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
padding: 1.5rem clamp(1.5rem, 5vw, 2.5rem);
|
|
95
|
+
padding-bottom: max(1.5rem, env(safe-area-inset-bottom, 1rem));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.auth-shell-fullsplit__form-inner {
|
|
99
|
+
width: 100%;
|
|
100
|
+
max-width: 360px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ── Desktop: reveal the image half, split 50/50 edge-to-edge ──────────── */
|
|
104
|
+
@media (min-width: 1024px) {
|
|
105
|
+
/* Mirror: media on the right instead of the left. */
|
|
106
|
+
.auth-shell-fullsplit[data-media-side="right"] {
|
|
107
|
+
flex-direction: row-reverse;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.auth-shell-fullsplit__media {
|
|
111
|
+
display: block;
|
|
112
|
+
position: relative;
|
|
113
|
+
flex: 1;
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
/* Always-present gradient scrim so brand/quote stay legible over any
|
|
116
|
+
photo, independent of any consumer-supplied overlay. Darkens bottom
|
|
117
|
+
(where the quote sits) and a touch at the top (brand). */
|
|
118
|
+
isolation: isolate;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.auth-shell-fullsplit__media::after {
|
|
122
|
+
content: "";
|
|
123
|
+
position: absolute;
|
|
124
|
+
inset: 0;
|
|
125
|
+
z-index: 1;
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
/* Two stacked scrims:
|
|
128
|
+
1. A strong shadow rising from the bottom (deep under the quote, easing
|
|
129
|
+
to nothing by the upper third) — the main legibility layer.
|
|
130
|
+
2. A faint shadow falling from the top so the brand mark stays readable
|
|
131
|
+
even over a bright sky. Kept light so the photo stays open.
|
|
132
|
+
Eased multi-stop curves avoid a hard "band" edge — natural vignette. */
|
|
133
|
+
background:
|
|
134
|
+
linear-gradient(
|
|
135
|
+
to top,
|
|
136
|
+
hsl(0 0% 0% / 0.78) 0%,
|
|
137
|
+
hsl(0 0% 0% / 0.55) 18%,
|
|
138
|
+
hsl(0 0% 0% / 0.28) 38%,
|
|
139
|
+
hsl(0 0% 0% / 0.08) 60%,
|
|
140
|
+
transparent 80%
|
|
141
|
+
),
|
|
142
|
+
linear-gradient(
|
|
143
|
+
to bottom,
|
|
144
|
+
hsl(0 0% 0% / 0.32) 0%,
|
|
145
|
+
hsl(0 0% 0% / 0.12) 12%,
|
|
146
|
+
transparent 26%
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Sidebar sits above the scrim. */
|
|
151
|
+
.auth-shell-fullsplit__sidebar {
|
|
152
|
+
z-index: 2;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.auth-shell-fullsplit__form {
|
|
156
|
+
flex: 1;
|
|
157
|
+
padding: 2.5rem;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.auth-shell-fullsplit__form-inner {
|
|
161
|
+
/* Slightly wider on the solid panel — it has room to breathe. */
|
|
162
|
+
max-width: 380px;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* Wider split bias on very large screens — form panel stays a comfortable
|
|
167
|
+
reading width while the image takes the extra space. */
|
|
168
|
+
@media (min-width: 1536px) {
|
|
169
|
+
.auth-shell-fullsplit__media {
|
|
170
|
+
flex: 1.25;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Split Shell Styles
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Mobile (< xl): a plain, frameless centered form — no card, no background
|
|
5
|
+
* image, no glass. Reads like an ordinary sign-in screen on small devices.
|
|
6
|
+
* Desktop (>= xl): a two-column frosted-glass card floating over the
|
|
7
|
+
* background image — translucent --card + backdrop blur, form left,
|
|
8
|
+
* sidebar right.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
.auth-shell-split {
|
|
@@ -16,72 +18,29 @@
|
|
|
16
18
|
padding: 1rem;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
/* Background layers */
|
|
20
|
-
.auth-shell-split__bg
|
|
21
|
-
position: fixed;
|
|
22
|
-
inset: 0;
|
|
23
|
-
z-index: 0;
|
|
24
|
-
pointer-events: none;
|
|
25
|
-
}
|
|
26
|
-
|
|
21
|
+
/* Background layers — hidden on mobile (plain form), shown on desktop. */
|
|
22
|
+
.auth-shell-split__bg,
|
|
27
23
|
.auth-shell-split__overlay {
|
|
24
|
+
display: none;
|
|
28
25
|
position: fixed;
|
|
29
26
|
inset: 0;
|
|
30
27
|
z-index: 0;
|
|
31
28
|
pointer-events: none;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
/* Card container
|
|
31
|
+
/* Card container.
|
|
32
|
+
Mobile: not a card at all — transparent, no border/shadow, just a centered
|
|
33
|
+
column. Desktop overrides below turn it into the frosted glass panel. */
|
|
35
34
|
.auth-shell-split__card {
|
|
36
35
|
position: relative;
|
|
37
36
|
z-index: 1;
|
|
38
37
|
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
39
|
width: 100%;
|
|
40
|
-
max-width:
|
|
41
|
-
background:
|
|
40
|
+
max-width: 400px;
|
|
41
|
+
background: transparent;
|
|
42
42
|
border-radius: var(--auth-radius);
|
|
43
|
-
outline: 1px solid var(--border);
|
|
44
|
-
box-shadow: 0 4px 24px hsl(0 0% 0% / 0.08);
|
|
45
43
|
overflow: hidden;
|
|
46
|
-
/* Apple-style frosted entry — the card "condenses" out of the
|
|
47
|
-
background. Uses the same spring curve (0.16, 1, 0.3, 1) that
|
|
48
|
-
iOS modal sheets ride on. We animate filter+transform+opacity
|
|
49
|
-
together so it reads as one continuous gesture, not three
|
|
50
|
-
stacked tweens. willChange hints the compositor to promote the
|
|
51
|
-
element ahead of the run; reset to auto on completion (CSS
|
|
52
|
-
`forwards` keeps the end state implicitly). */
|
|
53
|
-
animation: authShellCardEntry 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
54
|
-
will-change: transform, filter, opacity;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
@keyframes authShellCardEntry {
|
|
58
|
-
0% {
|
|
59
|
-
opacity: 0;
|
|
60
|
-
transform: translateY(24px) scale(0.96);
|
|
61
|
-
filter: blur(10px);
|
|
62
|
-
}
|
|
63
|
-
60% {
|
|
64
|
-
/* Mid-keyframe lets the blur clear faster than the slide so the
|
|
65
|
-
form contents become readable while the card is still settling
|
|
66
|
-
— feels responsive instead of waiting on the animation. */
|
|
67
|
-
filter: blur(0);
|
|
68
|
-
}
|
|
69
|
-
100% {
|
|
70
|
-
opacity: 1;
|
|
71
|
-
transform: translateY(0) scale(1);
|
|
72
|
-
filter: blur(0);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/* Respect reduced-motion preference — fall back to a plain fade. */
|
|
77
|
-
@media (prefers-reduced-motion: reduce) {
|
|
78
|
-
.auth-shell-split__card {
|
|
79
|
-
animation: authShellCardEntryReduced 200ms ease-out both;
|
|
80
|
-
}
|
|
81
|
-
@keyframes authShellCardEntryReduced {
|
|
82
|
-
from { opacity: 0; }
|
|
83
|
-
to { opacity: 1; }
|
|
84
|
-
}
|
|
85
44
|
}
|
|
86
45
|
|
|
87
46
|
/* Form column */
|
|
@@ -90,7 +49,7 @@
|
|
|
90
49
|
display: flex;
|
|
91
50
|
align-items: center;
|
|
92
51
|
justify-content: center;
|
|
93
|
-
padding:
|
|
52
|
+
padding: 1rem 0.5rem;
|
|
94
53
|
}
|
|
95
54
|
|
|
96
55
|
.auth-shell-split__form-inner {
|
|
@@ -98,43 +57,87 @@
|
|
|
98
57
|
max-width: 360px;
|
|
99
58
|
}
|
|
100
59
|
|
|
101
|
-
/* Sidebar column — hidden
|
|
60
|
+
/* Sidebar column — hidden on mobile, shown on desktop. */
|
|
102
61
|
.auth-shell-split__sidebar {
|
|
103
62
|
display: none;
|
|
104
|
-
flex: 1;
|
|
105
|
-
flex-direction: column;
|
|
106
|
-
justify-content: center;
|
|
107
|
-
padding: 2.5rem 2rem;
|
|
108
|
-
background: color-mix(in oklab, var(--muted) 25%, transparent);
|
|
109
63
|
}
|
|
110
64
|
|
|
111
|
-
/* Desktop:
|
|
65
|
+
/* ── Desktop: frosted-glass two-column card ──────────────────────────────── */
|
|
112
66
|
@media (min-width: 1280px) {
|
|
67
|
+
.auth-shell-split__bg,
|
|
68
|
+
.auth-shell-split__overlay {
|
|
69
|
+
display: block;
|
|
70
|
+
}
|
|
71
|
+
|
|
113
72
|
.auth-shell-split__card {
|
|
73
|
+
flex-direction: row;
|
|
114
74
|
max-width: 960px;
|
|
75
|
+
/* Give the card real presence on desktop — a taller panel reads as a
|
|
76
|
+
considered layout rather than a thin strip, and gives the sidebar room
|
|
77
|
+
to breathe. */
|
|
78
|
+
min-height: 560px;
|
|
79
|
+
/* Translucent surface + backdrop blur so the background image reads
|
|
80
|
+
through the panel as frosted glass. color-mix keeps it theme-aware. */
|
|
81
|
+
background: color-mix(in oklab, var(--card) 72%, transparent);
|
|
82
|
+
backdrop-filter: blur(20px) saturate(140%);
|
|
83
|
+
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
|
84
|
+
color: var(--card-foreground);
|
|
85
|
+
outline: 1px solid color-mix(in oklab, var(--border) 80%, transparent);
|
|
86
|
+
box-shadow: 0 24px 64px hsl(0 0% 0% / 0.28);
|
|
87
|
+
/* Apple-style frosted entry — the card "condenses" out of the background.
|
|
88
|
+
Same spring curve (0.16, 1, 0.3, 1) iOS modal sheets ride on. */
|
|
89
|
+
animation: authShellCardEntry 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
90
|
+
will-change: transform, filter, opacity;
|
|
115
91
|
}
|
|
116
92
|
|
|
117
93
|
.auth-shell-split__form {
|
|
118
|
-
padding: 2.
|
|
94
|
+
padding: 2.75rem;
|
|
119
95
|
}
|
|
120
96
|
|
|
121
97
|
.auth-shell-split__sidebar {
|
|
122
98
|
display: flex;
|
|
99
|
+
flex: 1;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
justify-content: space-between;
|
|
102
|
+
gap: 2rem;
|
|
103
|
+
padding: 2.75rem 2.25rem;
|
|
104
|
+
/* A subtle tint over the glass marks the brand column without becoming a
|
|
105
|
+
second opaque surface. */
|
|
106
|
+
background: color-mix(in oklab, var(--muted) 40%, transparent);
|
|
107
|
+
border-left: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
|
123
108
|
}
|
|
124
109
|
}
|
|
125
110
|
|
|
126
|
-
/*
|
|
111
|
+
/* Tablet (1024–1279): keep mobile's plain form but allow a touch more room. */
|
|
127
112
|
@media (min-width: 1024px) and (max-width: 1279px) {
|
|
128
113
|
.auth-shell-split__card {
|
|
129
|
-
max-width:
|
|
114
|
+
max-width: 420px;
|
|
130
115
|
}
|
|
116
|
+
}
|
|
131
117
|
|
|
132
|
-
|
|
133
|
-
|
|
118
|
+
@keyframes authShellCardEntry {
|
|
119
|
+
0% {
|
|
120
|
+
opacity: 0;
|
|
121
|
+
transform: translateY(24px) scale(0.96);
|
|
122
|
+
filter: blur(10px);
|
|
123
|
+
}
|
|
124
|
+
60% {
|
|
125
|
+
filter: blur(0);
|
|
126
|
+
}
|
|
127
|
+
100% {
|
|
128
|
+
opacity: 1;
|
|
129
|
+
transform: translateY(0) scale(1);
|
|
130
|
+
filter: blur(0);
|
|
134
131
|
}
|
|
132
|
+
}
|
|
135
133
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
/* Respect reduced-motion — fall back to a plain fade. */
|
|
135
|
+
@media (prefers-reduced-motion: reduce) {
|
|
136
|
+
.auth-shell-split__card {
|
|
137
|
+
animation: authShellCardEntryReduced 200ms ease-out both;
|
|
138
|
+
}
|
|
139
|
+
@keyframes authShellCardEntryReduced {
|
|
140
|
+
from { opacity: 0; }
|
|
141
|
+
to { opacity: 1; }
|
|
139
142
|
}
|
|
140
143
|
}
|
|
@@ -44,11 +44,6 @@ export interface AuthLayoutConfig {
|
|
|
44
44
|
logoUrl?: string;
|
|
45
45
|
/** URL to redirect after successful auth (default: /dashboard) */
|
|
46
46
|
redirectUrl?: string;
|
|
47
|
-
/**
|
|
48
|
-
* Enable 2FA setup prompt after successful authentication.
|
|
49
|
-
* @default true
|
|
50
|
-
*/
|
|
51
|
-
enable2FASetup?: boolean;
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -64,8 +59,13 @@ export interface AuthFormContextType extends AuthFormReturn, AuthLayoutConfig {}
|
|
|
64
59
|
export interface AuthLayoutProps extends AuthLayoutConfig {
|
|
65
60
|
children?: React.ReactNode;
|
|
66
61
|
className?: string;
|
|
67
|
-
/** Shell variant — controls the overall layout shape. @default '
|
|
62
|
+
/** Shell variant — controls the overall layout shape. @default 'fullsplit' */
|
|
68
63
|
variant?: AuthShellVariant;
|
|
64
|
+
/**
|
|
65
|
+
* Which side the media (image) half sits on in the `fullsplit` variant.
|
|
66
|
+
* Ignored by other variants. @default 'left'
|
|
67
|
+
*/
|
|
68
|
+
mediaSide?: 'left' | 'right';
|
|
69
69
|
/** Background configuration (image, gradient, overlay, blur). */
|
|
70
70
|
background?: AuthBackgroundConfig;
|
|
71
71
|
/** Slot for the right column in split variant (testimonial, branding, etc.). */
|
|
@@ -53,7 +53,6 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
53
53
|
termsUrl,
|
|
54
54
|
privacyUrl,
|
|
55
55
|
enableGithubAuth = false,
|
|
56
|
-
enable2FASetup = true,
|
|
57
56
|
logoUrl,
|
|
58
57
|
redirectUrl = '/dashboard',
|
|
59
58
|
|
|
@@ -152,7 +151,6 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
152
151
|
termsUrl,
|
|
153
152
|
privacyUrl,
|
|
154
153
|
enableGithubAuth,
|
|
155
|
-
enable2FASetup,
|
|
156
154
|
logoUrl,
|
|
157
155
|
redirectUrl,
|
|
158
156
|
};
|
|
@@ -176,7 +174,6 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
176
174
|
termsUrl,
|
|
177
175
|
privacyUrl,
|
|
178
176
|
enableGithubAuth,
|
|
179
|
-
enable2FASetup,
|
|
180
177
|
logoUrl,
|
|
181
178
|
redirectUrl,
|
|
182
179
|
]);
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { memo, useMemo } from 'react';
|
|
4
|
-
|
|
5
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
6
|
-
import { Checkbox } from '@djangocfg/ui-core/components';
|
|
7
|
-
|
|
8
|
-
export interface TermsCheckboxProps {
|
|
9
|
-
checked: boolean;
|
|
10
|
-
onChange: (checked: boolean) => void;
|
|
11
|
-
termsUrl?: string;
|
|
12
|
-
privacyUrl?: string;
|
|
13
|
-
disabled?: boolean;
|
|
14
|
-
className?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* TermsCheckbox - Compact terms acceptance checkbox.
|
|
19
|
-
*
|
|
20
|
-
* Memoised: re-renders only when checked, termsUrl, privacyUrl, disabled
|
|
21
|
-
* or className change. `onChange` is compared by reference — callers
|
|
22
|
-
* should stabilise it with useCallback.
|
|
23
|
-
*/
|
|
24
|
-
function TermsCheckboxRaw({
|
|
25
|
-
checked,
|
|
26
|
-
onChange,
|
|
27
|
-
termsUrl,
|
|
28
|
-
privacyUrl,
|
|
29
|
-
disabled = false,
|
|
30
|
-
className = '',
|
|
31
|
-
}: TermsCheckboxProps) {
|
|
32
|
-
const t = useAppT();
|
|
33
|
-
const labels = useMemo(() => ({
|
|
34
|
-
agree: t('layouts.auth.terms.agree'),
|
|
35
|
-
and: t('layouts.auth.terms.and'),
|
|
36
|
-
terms: t('layouts.auth.terms.terms'),
|
|
37
|
-
privacy: t('layouts.auth.terms.privacy'),
|
|
38
|
-
}), [t]);
|
|
39
|
-
|
|
40
|
-
// Don't render if no links provided
|
|
41
|
-
if (!termsUrl && !privacyUrl) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<div className={`auth-terms ${className}`}>
|
|
47
|
-
<Checkbox
|
|
48
|
-
id="auth-terms"
|
|
49
|
-
checked={checked}
|
|
50
|
-
onCheckedChange={onChange}
|
|
51
|
-
disabled={disabled}
|
|
52
|
-
className="auth-terms-checkbox"
|
|
53
|
-
/>
|
|
54
|
-
<label htmlFor="auth-terms">
|
|
55
|
-
{labels.agree}{' '}
|
|
56
|
-
{termsUrl && (
|
|
57
|
-
<a href={termsUrl} target="_blank" rel="noopener noreferrer">
|
|
58
|
-
{labels.terms}
|
|
59
|
-
</a>
|
|
60
|
-
)}
|
|
61
|
-
{termsUrl && privacyUrl && ` ${labels.and} `}
|
|
62
|
-
{privacyUrl && (
|
|
63
|
-
<a href={privacyUrl} target="_blank" rel="noopener noreferrer">
|
|
64
|
-
{labels.privacy}
|
|
65
|
-
</a>
|
|
66
|
-
)}
|
|
67
|
-
</label>
|
|
68
|
-
</div>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const TermsCheckbox = memo(TermsCheckboxRaw);
|