@drmhse/authos-react 0.1.2 → 0.1.4
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/README.md +263 -147
- package/dist/index.d.mts +263 -15
- package/dist/index.d.ts +263 -15
- package/dist/index.js +277 -43
- package/dist/index.mjs +274 -45
- package/dist/nextjs.d.mts +30 -1
- package/dist/nextjs.d.ts +30 -1
- package/dist/nextjs.js +11 -0
- package/dist/nextjs.mjs +10 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,156 +5,224 @@
|
|
|
5
5
|
|
|
6
6
|
React adapter for [AuthOS](https://authos.dev) - the multi-tenant authentication platform. Provides React hooks, components, and Next.js integration.
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Quick Start (5 minutes)
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
npm install @drmhse/authos-react
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
Wrap your app with `AuthOSProvider` and you're ready to go:
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { AuthOSProvider, SignIn, SignedIn, SignedOut, UserButton } from '@drmhse/authos-react';
|
|
18
|
+
|
|
19
|
+
function App() {
|
|
20
|
+
return (
|
|
21
|
+
<AuthOSProvider config={{ baseURL: 'https://sso.example.com' }}>
|
|
22
|
+
<SignedOut>
|
|
23
|
+
<SignIn onSuccess={(user) => console.log('Welcome', user.email)} />
|
|
24
|
+
</SignedOut>
|
|
25
|
+
<SignedIn>
|
|
26
|
+
<UserButton />
|
|
27
|
+
<p>You're signed in!</p>
|
|
28
|
+
</SignedIn>
|
|
29
|
+
</AuthOSProvider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's it. You now have:
|
|
35
|
+
- ✅ Email/password authentication with MFA support
|
|
36
|
+
- ✅ Automatic session management
|
|
37
|
+
- ✅ User dropdown with logout
|
|
38
|
+
- ✅ Conditional rendering based on auth state
|
|
39
|
+
|
|
40
|
+
## Usage Modes
|
|
41
|
+
|
|
42
|
+
AuthOS supports two usage modes:
|
|
43
|
+
|
|
44
|
+
### Platform-Level Access
|
|
45
|
+
|
|
46
|
+
For platform owners and administrators, use without `org`/`service`:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<AuthOSProvider config={{ baseURL: 'https://sso.example.com' }}>
|
|
50
|
+
<SignIn />
|
|
51
|
+
</AuthOSProvider>
|
|
17
52
|
```
|
|
18
53
|
|
|
19
|
-
|
|
54
|
+
### Multi-Tenant Access (Organizations)
|
|
55
|
+
|
|
56
|
+
For tenant applications with OAuth support, add `org` and `service`:
|
|
20
57
|
|
|
21
|
-
|
|
58
|
+
```tsx
|
|
59
|
+
<AuthOSProvider config={{
|
|
60
|
+
baseURL: 'https://sso.example.com',
|
|
61
|
+
org: 'acme-corp', // Organization slug
|
|
62
|
+
service: 'main-app', // Service slug
|
|
63
|
+
}}>
|
|
64
|
+
<SignIn providers={['github', 'google']} />
|
|
65
|
+
</AuthOSProvider>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To use OAuth providers (GitHub, Google, Microsoft), you need to configure your organization and service in the provider:
|
|
22
69
|
|
|
23
70
|
```tsx
|
|
71
|
+
<AuthOSProvider config={{
|
|
72
|
+
baseURL: 'https://sso.example.com',
|
|
73
|
+
org: 'my-org', // Your organization slug
|
|
74
|
+
service: 'my-app', // Your service slug
|
|
75
|
+
redirectUri: 'https://app.example.com/callback', // Optional
|
|
76
|
+
}}>
|
|
77
|
+
<SignIn providers={['github', 'google', 'microsoft']} />
|
|
78
|
+
</AuthOSProvider>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Using an Existing Client
|
|
82
|
+
|
|
83
|
+
For advanced use cases, you can pass a pre-configured `SsoClient`:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { SsoClient } from '@drmhse/sso-sdk';
|
|
24
87
|
import { AuthOSProvider } from '@drmhse/authos-react';
|
|
25
88
|
|
|
89
|
+
// Create client with custom configuration
|
|
90
|
+
const client = new SsoClient({
|
|
91
|
+
baseURL: 'https://sso.example.com',
|
|
92
|
+
storage: customStorage,
|
|
93
|
+
});
|
|
94
|
+
|
|
26
95
|
function App() {
|
|
27
96
|
return (
|
|
28
|
-
<AuthOSProvider
|
|
29
|
-
|
|
30
|
-
baseURL: 'https://sso.example.com'
|
|
31
|
-
}}
|
|
32
|
-
>
|
|
33
|
-
<YourApp />
|
|
97
|
+
<AuthOSProvider client={client}>
|
|
98
|
+
<SignIn />
|
|
34
99
|
</AuthOSProvider>
|
|
35
100
|
);
|
|
36
101
|
}
|
|
102
|
+
|
|
103
|
+
Or use individual OAuth buttons:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
import { OAuthButton } from '@drmhse/authos-react';
|
|
107
|
+
|
|
108
|
+
<OAuthButton provider="github">Sign in with GitHub</OAuthButton>
|
|
109
|
+
<OAuthButton provider="google" />
|
|
110
|
+
<OAuthButton provider="microsoft" />
|
|
37
111
|
```
|
|
38
112
|
|
|
39
113
|
## Components
|
|
40
114
|
|
|
41
115
|
### SignIn
|
|
42
116
|
|
|
43
|
-
|
|
117
|
+
Complete sign-in form with email/password authentication and optional OAuth buttons.
|
|
44
118
|
|
|
45
119
|
```tsx
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
55
|
-
}
|
|
120
|
+
<SignIn
|
|
121
|
+
onSuccess={(user) => console.log('Logged in:', user)}
|
|
122
|
+
onError={(error) => console.error(error)}
|
|
123
|
+
providers={['github', 'google']} // Optional OAuth buttons
|
|
124
|
+
showForgotPassword={true} // Show forgot password link
|
|
125
|
+
showSignUp={true} // Show sign up link
|
|
126
|
+
showDivider={true} // Show "or" divider between OAuth and email form
|
|
127
|
+
/>
|
|
56
128
|
```
|
|
57
129
|
|
|
58
130
|
**Props:**
|
|
59
|
-
| Prop | Type | Description |
|
|
60
|
-
|
|
61
|
-
| `onSuccess` | `() => void` | Callback after successful login |
|
|
62
|
-
| `onError` | `(error: Error) => void` | Callback on login error |
|
|
131
|
+
| Prop | Type | Default | Description |
|
|
132
|
+
|------|------|---------|-------------|
|
|
133
|
+
| `onSuccess` | `(user: UserProfile) => void` | - | Callback after successful login |
|
|
134
|
+
| `onError` | `(error: Error) => void` | - | Callback on login error |
|
|
135
|
+
| `providers` | `('github' \| 'google' \| 'microsoft')[] \| false` | `false` | OAuth providers to display |
|
|
136
|
+
| `showForgotPassword` | `boolean` | `true` | Show forgot password link |
|
|
137
|
+
| `showSignUp` | `boolean` | `true` | Show sign up link |
|
|
138
|
+
| `showDivider` | `boolean` | `true` | Show divider between OAuth and email form |
|
|
139
|
+
| `className` | `string` | - | Custom class name |
|
|
63
140
|
|
|
64
141
|
### SignUp
|
|
65
142
|
|
|
66
143
|
Registration form for new users.
|
|
67
144
|
|
|
68
145
|
```tsx
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
onSuccess={() => console.log('Registered!')}
|
|
75
|
-
onError={(err) => console.error(err)}
|
|
76
|
-
/>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
146
|
+
<SignUp
|
|
147
|
+
onSuccess={() => console.log('Check your email!')}
|
|
148
|
+
onError={(error) => console.error(error)}
|
|
149
|
+
orgSlug="my-org" // Optional: pre-fill organization
|
|
150
|
+
/>
|
|
79
151
|
```
|
|
80
152
|
|
|
81
|
-
|
|
153
|
+
### SignedIn / SignedOut
|
|
154
|
+
|
|
155
|
+
Conditional rendering based on authentication state. Inspired by Clerk's API.
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
<SignedIn>
|
|
159
|
+
{/* Only shown when user is logged in */}
|
|
160
|
+
<UserButton />
|
|
161
|
+
</SignedIn>
|
|
162
|
+
|
|
163
|
+
<SignedOut>
|
|
164
|
+
{/* Only shown when user is logged out */}
|
|
165
|
+
<SignIn />
|
|
166
|
+
</SignedOut>
|
|
167
|
+
```
|
|
82
168
|
|
|
83
169
|
### UserButton
|
|
84
170
|
|
|
85
|
-
User menu button
|
|
171
|
+
User menu button with avatar initials and dropdown with profile/signout.
|
|
86
172
|
|
|
87
173
|
```tsx
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
174
|
+
<UserButton
|
|
175
|
+
showEmail={true}
|
|
176
|
+
onLogout={() => router.push('/')}
|
|
177
|
+
/>
|
|
93
178
|
```
|
|
94
179
|
|
|
95
180
|
### OrganizationSwitcher
|
|
96
181
|
|
|
97
182
|
Dropdown to switch between organizations (for multi-tenant users).
|
|
98
183
|
|
|
99
|
-
|
|
100
|
-
import { OrganizationSwitcher } from '@drmhse/authos-react';
|
|
184
|
+
When switching organizations, the SDK automatically issues new JWT tokens with the new organization context, enabling seamless multi-tenant switching without re-authentication.
|
|
101
185
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
186
|
+
```tsx
|
|
187
|
+
<OrganizationSwitcher
|
|
188
|
+
onSwitch={(org) => console.log('Switched to:', org.name)}
|
|
189
|
+
/>
|
|
105
190
|
```
|
|
106
191
|
|
|
107
192
|
### Protect
|
|
108
193
|
|
|
109
|
-
Conditional rendering based on user permissions.
|
|
194
|
+
Conditional rendering based on user permissions or roles.
|
|
110
195
|
|
|
111
196
|
```tsx
|
|
112
|
-
|
|
197
|
+
<Protect permission="admin:access" fallback={<p>Access denied</p>}>
|
|
198
|
+
<AdminDashboard />
|
|
199
|
+
</Protect>
|
|
113
200
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
permission="admin:access"
|
|
118
|
-
fallback={<p>Access denied. Admins only.</p>}
|
|
119
|
-
>
|
|
120
|
-
<AdminDashboard />
|
|
121
|
-
</Protect>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
201
|
+
<Protect role="owner">
|
|
202
|
+
<DangerZone />
|
|
203
|
+
</Protect>
|
|
124
204
|
```
|
|
125
205
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
206
|
+
### OAuthButton
|
|
207
|
+
|
|
208
|
+
Individual OAuth provider button. Requires `org` and `service` in provider config.
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
<OAuthButton provider="github" />
|
|
212
|
+
<OAuthButton provider="google">Continue with Google</OAuthButton>
|
|
213
|
+
```
|
|
132
214
|
|
|
133
215
|
## Hooks
|
|
134
216
|
|
|
135
217
|
### useAuthOS
|
|
136
218
|
|
|
137
|
-
Access the AuthOS client
|
|
219
|
+
Access the AuthOS client and auth state.
|
|
138
220
|
|
|
139
221
|
```tsx
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
function Profile() {
|
|
143
|
-
const { client, isAuthenticated, isLoading } = useAuthOS();
|
|
222
|
+
const { client, config, isAuthenticated, isLoading } = useAuthOS();
|
|
144
223
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const handleLogout = async () => {
|
|
149
|
-
await client.auth.logout();
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
return (
|
|
153
|
-
<div>
|
|
154
|
-
<button onClick={handleLogout}>Logout</button>
|
|
155
|
-
</div>
|
|
156
|
-
);
|
|
157
|
-
}
|
|
224
|
+
// Use the client directly
|
|
225
|
+
await client.auth.logout();
|
|
158
226
|
```
|
|
159
227
|
|
|
160
228
|
### useUser
|
|
@@ -162,115 +230,163 @@ function Profile() {
|
|
|
162
230
|
Get the current user's profile.
|
|
163
231
|
|
|
164
232
|
```tsx
|
|
165
|
-
|
|
233
|
+
const { user, isLoading } = useUser();
|
|
166
234
|
|
|
167
|
-
|
|
168
|
-
|
|
235
|
+
if (isLoading) return <Spinner />;
|
|
236
|
+
if (!user) return <SignIn />;
|
|
169
237
|
|
|
170
|
-
|
|
171
|
-
return <div>Welcome, {user?.email}</div>;
|
|
172
|
-
}
|
|
238
|
+
return <p>Welcome, {user.email}</p>;
|
|
173
239
|
```
|
|
174
240
|
|
|
175
241
|
### useOrganization
|
|
176
242
|
|
|
177
|
-
Get
|
|
243
|
+
Get and switch between organizations.
|
|
178
244
|
|
|
179
245
|
```tsx
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
{organizations.map((org) => (
|
|
190
|
-
<li key={org.id}>{org.name}</li>
|
|
191
|
-
))}
|
|
192
|
-
</ul>
|
|
193
|
-
</div>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
246
|
+
const {
|
|
247
|
+
currentOrganization,
|
|
248
|
+
organizations,
|
|
249
|
+
switchOrganization,
|
|
250
|
+
isSwitching
|
|
251
|
+
} = useOrganization();
|
|
252
|
+
|
|
253
|
+
// Switch to a different org
|
|
254
|
+
await switchOrganization('other-org-slug');
|
|
196
255
|
```
|
|
197
256
|
|
|
198
|
-
### usePermission
|
|
257
|
+
### usePermission / useAnyPermission / useAllPermissions
|
|
199
258
|
|
|
200
259
|
Check user permissions.
|
|
201
260
|
|
|
202
261
|
```tsx
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const canAccessAdmin = usePermission('admin:access');
|
|
207
|
-
|
|
208
|
-
if (!canAccessAdmin) return null;
|
|
262
|
+
const canAccessAdmin = usePermission('admin:access');
|
|
263
|
+
const canReadOrWrite = useAnyPermission(['read:data', 'write:data']);
|
|
264
|
+
const hasFullAccess = useAllPermissions(['read:data', 'write:data', 'delete:data']);
|
|
209
265
|
|
|
210
|
-
|
|
211
|
-
}
|
|
266
|
+
if (!canAccessAdmin) return <AccessDenied />;
|
|
212
267
|
```
|
|
213
268
|
|
|
214
269
|
## Next.js Integration
|
|
215
270
|
|
|
216
|
-
|
|
271
|
+
### App Router
|
|
217
272
|
|
|
218
273
|
```tsx
|
|
219
|
-
//
|
|
220
|
-
import {
|
|
274
|
+
// app/layout.tsx
|
|
275
|
+
import { AuthOSProvider } from '@drmhse/authos-react';
|
|
276
|
+
import { cookies } from 'next/headers';
|
|
221
277
|
|
|
222
|
-
export default
|
|
278
|
+
export default async function RootLayout({ children }) {
|
|
279
|
+
const cookieStore = cookies();
|
|
280
|
+
const token = cookieStore.get('authos_token')?.value;
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<html>
|
|
284
|
+
<body>
|
|
285
|
+
<AuthOSProvider
|
|
286
|
+
config={{ baseURL: process.env.NEXT_PUBLIC_SSO_URL! }}
|
|
287
|
+
initialSessionToken={token}
|
|
288
|
+
>
|
|
289
|
+
{children}
|
|
290
|
+
</AuthOSProvider>
|
|
291
|
+
</body>
|
|
292
|
+
</html>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
223
295
|
```
|
|
224
296
|
|
|
297
|
+
### Middleware (Next.js)
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// middleware.ts
|
|
301
|
+
import { authMiddleware, getJwksUrl } from '@drmhse/authos-react/nextjs';
|
|
302
|
+
|
|
303
|
+
export default authMiddleware({
|
|
304
|
+
jwksUrl: getJwksUrl(process.env.NEXT_PUBLIC_SSO_URL!),
|
|
305
|
+
protectedRoutes: ['/dashboard/*', '/settings/*'],
|
|
306
|
+
publicRoutes: ['/', '/about', '/pricing'],
|
|
307
|
+
signInUrl: '/signin',
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
export const config = {
|
|
311
|
+
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
|
312
|
+
};
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Server Components
|
|
316
|
+
|
|
225
317
|
```tsx
|
|
226
318
|
// app/dashboard/page.tsx
|
|
227
319
|
import { currentUser } from '@drmhse/authos-react/nextjs';
|
|
228
320
|
|
|
229
321
|
export default async function Dashboard() {
|
|
230
322
|
const user = await currentUser();
|
|
231
|
-
|
|
323
|
+
|
|
232
324
|
if (!user) {
|
|
233
|
-
|
|
325
|
+
redirect('/login');
|
|
234
326
|
}
|
|
235
327
|
|
|
236
328
|
return <div>Welcome, {user.email}</div>;
|
|
237
329
|
}
|
|
238
330
|
```
|
|
239
331
|
|
|
240
|
-
##
|
|
332
|
+
## Configuration Reference
|
|
241
333
|
|
|
242
334
|
### AuthOSProvider Props
|
|
243
335
|
|
|
244
|
-
| Prop | Type |
|
|
245
|
-
|
|
246
|
-
| `config.baseURL` | `string` |
|
|
247
|
-
| `config.
|
|
248
|
-
| `config.
|
|
249
|
-
| `config.
|
|
336
|
+
| Prop | Type | Required | Description |
|
|
337
|
+
|------|------|----------|-------------|
|
|
338
|
+
| `config.baseURL` | `string` | ✅ | AuthOS API URL |
|
|
339
|
+
| `config.org` | `string` | For OAuth | Organization slug for OAuth flows |
|
|
340
|
+
| `config.service` | `string` | For OAuth | Service slug for OAuth flows |
|
|
341
|
+
| `config.redirectUri` | `string` | - | OAuth redirect URI (defaults to origin + '/callback') |
|
|
342
|
+
| `config.afterSignInUrl` | `string` | - | Redirect URL after sign-in |
|
|
343
|
+
| `config.afterSignUpUrl` | `string` | - | Redirect URL after sign-up |
|
|
344
|
+
| `config.storage` | `TokenStorage` | - | Custom token storage |
|
|
345
|
+
| `client` | `SsoClient` | - | Use existing client instance |
|
|
346
|
+
| `initialSessionToken` | `string` | - | SSR token for hydration |
|
|
347
|
+
|
|
348
|
+
## Styling
|
|
349
|
+
|
|
350
|
+
All components use data attributes for styling hooks. Use CSS selectors:
|
|
351
|
+
|
|
352
|
+
```css
|
|
353
|
+
[data-authos-signin] { /* Container */ }
|
|
354
|
+
[data-authos-field="email"] { /* Email field wrapper */ }
|
|
355
|
+
[data-authos-field="password"] { /* Password field wrapper */ }
|
|
356
|
+
[data-authos-submit] { /* Submit button */ }
|
|
357
|
+
[data-authos-error] { /* Error message */ }
|
|
358
|
+
[data-authos-oauth] { /* OAuth button */ }
|
|
359
|
+
[data-authos-oauth][data-provider="github"] { /* GitHub button */ }
|
|
360
|
+
[data-authos-divider] { /* "or" divider */ }
|
|
361
|
+
```
|
|
250
362
|
|
|
251
|
-
|
|
363
|
+
## Security Considerations
|
|
252
364
|
|
|
253
|
-
|
|
365
|
+
### Token Storage
|
|
254
366
|
|
|
255
|
-
|
|
256
|
-
const { client } = useAuthOS();
|
|
367
|
+
The SDK offers multiple storage options:
|
|
257
368
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
369
|
+
| Storage | Environment | Security Notes |
|
|
370
|
+
|---------|-------------|----------------|
|
|
371
|
+
| `BrowserStorage` | Client-side | Uses localStorage, accessible to JS |
|
|
372
|
+
| `CookieStorage` | SSR (Next.js) | Non-httpOnly cookies, accessible to JS |
|
|
373
|
+
| `MemoryStorage` | SSR/Testing | In-memory, lost on page refresh |
|
|
262
374
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
375
|
+
> [!WARNING]
|
|
376
|
+
> `CookieStorage` and `BrowserStorage` store tokens accessible to JavaScript.
|
|
377
|
+
> For maximum security in production, consider:
|
|
378
|
+
> - Using httpOnly cookies set by your backend
|
|
379
|
+
> - Implementing a Backend-for-Frontend (BFF) pattern
|
|
380
|
+
> - Ensuring proper CSP headers to mitigate XSS
|
|
267
381
|
|
|
268
|
-
|
|
269
|
-
await client.organizations.list();
|
|
270
|
-
await client.organizations.get(slug);
|
|
382
|
+
### JWT Verification
|
|
271
383
|
|
|
272
|
-
|
|
273
|
-
|
|
384
|
+
The Next.js middleware and Node adapter provide independent JWT verification:
|
|
385
|
+
- **Next.js middleware**: Uses Web Crypto API (`crypto.subtle`) for Edge Runtime
|
|
386
|
+
- **Node adapter**: Uses Node.js `crypto` module
|
|
387
|
+
|
|
388
|
+
This duplication is intentional due to runtime differences. Both implementations
|
|
389
|
+
verify signatures using your JWKS endpoint.
|
|
274
390
|
|
|
275
391
|
## License
|
|
276
392
|
|