Initial commit
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/.idea/
|
51
README.md
Normal file
|
@ -0,0 +1,51 @@
|
|||
# Next-Ory
|
||||
|
||||
Get started with ORY authentication quickly and easily.
|
||||
|
||||
> [!Warning]
|
||||
> This project is work in progress. There is no guarantee that everything will work as it should and breaking changes in
|
||||
> the future are possible.
|
||||
|
||||
The goal of this project is to create an easy-to-use setup to self-host [Ory Kratos](https://www.ory.sh/kratos)
|
||||
and [Ory Hydra](https://www.ory.sh/hydra). It will contain an authentication UI, implementing all self-service flows for
|
||||
Ory Kratos and Ory Hydra, as well as an admin UI. All UI components are written in NextJS and Typescript, and styled
|
||||
using shadcn/ui and TailwindCSS.
|
||||
|
||||
## Getting started
|
||||
|
||||
Start the backend services using Docker Compose:
|
||||
|
||||
```bash
|
||||
cp /docker/ory-dev/.env.example /docker/ory-dev/.env
|
||||
docker compose -f docker/ory-dev/docker-compose.yaml up -d
|
||||
|
||||
# optional to test consent flow
|
||||
sh docker/ory-dev/hydra-test-consent.sh
|
||||
```
|
||||
|
||||
Then start the authentication UI using npm:
|
||||
|
||||
```bash
|
||||
cd authentication
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
*soon.*
|
||||
|
||||
## Authentication UI
|
||||
|
||||
The authentication UI is already implemented and working. It supports all self-service flows for Ory Kratos and Ory
|
||||
Hydra. It is implemented in a way, that customizing style and page layout is very easy.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Admin UI
|
||||
|
||||
*soon.*
|
||||
|
4
authentication/.env.example
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
ORY_HYDRA_ADMIN_URL=http://localhost:4445
|
||||
|
||||
NEXT_PUBLIC_ORY_KRATOS_URL=http://localhost:4433
|
3
authentication/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
40
authentication/.gitignore
vendored
Normal file
|
@ -0,0 +1,40 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# serwist
|
||||
public/sw.js
|
49
authentication/Dockerfile
Normal file
|
@ -0,0 +1,49 @@
|
|||
FROM node:21-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
9
authentication/README.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Next-Ory - Authentication
|
||||
|
||||
This directory contains a NextJS 14 (app router) UI Node, implementing all Ory Kratos and Ory Hydra UI flows.
|
||||
|
||||
## Stack
|
||||
|
||||
- [NextJS](https://nextjs.org/)
|
||||
- [TailwindCSS](https://tailwindcss.com/)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
17
authentication/components.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
13
authentication/next.config.mjs
Normal file
|
@ -0,0 +1,13 @@
|
|||
import withSerwistInit from '@serwist/next';
|
||||
|
||||
const withSerwist = withSerwistInit({
|
||||
swSrc: 'src/app/service-worker.ts',
|
||||
swDest: 'public/sw.js',
|
||||
});
|
||||
|
||||
export default withSerwist({
|
||||
output: 'standalone',
|
||||
env: {
|
||||
appVersion: process.env.npm_package_version,
|
||||
},
|
||||
});
|
7042
authentication/package-lock.json
generated
Normal file
52
authentication/package.json
Normal file
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "next-base",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@ory/client": "^1.9.0",
|
||||
"@ory/integrations": "^1.1.5",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@serwist/next": "^9.0.0-preview.21",
|
||||
"@serwist/precaching": "^9.0.0-preview.21",
|
||||
"@serwist/sw": "^9.0.0-preview.21",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.350.0",
|
||||
"next": "14.1.3",
|
||||
"next-themes": "^0.2.1",
|
||||
"oslo": "^1.1.3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.51.0",
|
||||
"sonner": "^1.4.3",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.25",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.3",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
6
authentication/postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
BIN
authentication/public/favicon.png
Normal file
After Width: | Height: | Size: 6 KiB |
BIN
authentication/public/icon-128.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
authentication/public/icon-144.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
authentication/public/icon-192.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
authentication/public/icon-512.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
authentication/public/icon-72.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
37
authentication/public/manifest.json
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "Next Base",
|
||||
"short_name": "Next Base",
|
||||
"description": "The starting point for your next project",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#0B0908",
|
||||
"background_color": "#0B0908",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait"
|
||||
}
|
BIN
authentication/public/mt-logo-orange.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
88
authentication/src/app/flow/consent/page.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
'use server';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import getHydra from '@/ory/sdk/hydra';
|
||||
import { OAuth2ConsentRequest, OAuth2RedirectTo } from '@ory/client';
|
||||
import ConsentForm from '@/components/consentForm';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default async function Consent({ searchParams }: { searchParams: { consent_challenge: string } }) {
|
||||
|
||||
const consentChallenge = searchParams.consent_challenge ?? undefined;
|
||||
let consentRequest: OAuth2ConsentRequest | undefined = undefined;
|
||||
|
||||
const onAccept = async (challenge: string, scopes: string[], remember: boolean) => {
|
||||
'use server';
|
||||
|
||||
const hydra = await getHydra();
|
||||
const response = await hydra
|
||||
.acceptOAuth2ConsentRequest({
|
||||
consentChallenge: challenge,
|
||||
acceptOAuth2ConsentRequest: {
|
||||
grant_scope: scopes,
|
||||
remember: remember,
|
||||
remember_for: 3600,
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data)
|
||||
.catch((_) => {
|
||||
toast.error('Something unexpected went wrong.');
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return redirect(response.redirect_to);
|
||||
};
|
||||
|
||||
const onReject = async (challenge: string) => {
|
||||
'use server';
|
||||
|
||||
const hydra = await getHydra();
|
||||
const response: OAuth2RedirectTo | void = await hydra
|
||||
.rejectOAuth2ConsentRequest({
|
||||
consentChallenge: challenge,
|
||||
})
|
||||
.then(({ data }) => data)
|
||||
.catch((_) => {
|
||||
toast.error('Something unexpected went wrong.');
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return redirect(response.redirect_to);
|
||||
};
|
||||
|
||||
if (!consentChallenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hydra = await getHydra();
|
||||
await hydra
|
||||
.getOAuth2ConsentRequest({ consentChallenge })
|
||||
.then(({ data }) => {
|
||||
if (data.skip) {
|
||||
onAccept(consentChallenge, data.requested_scope!, false);
|
||||
return;
|
||||
}
|
||||
consentRequest = data;
|
||||
});
|
||||
|
||||
if (!consentRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col items-center w-full max-w-sm p-4">
|
||||
<ConsentForm
|
||||
request={consentRequest}
|
||||
onAccept={onAccept}
|
||||
onReject={onReject}/>
|
||||
</Card>
|
||||
);
|
||||
}
|
71
authentication/src/app/flow/error/page.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { FlowError } from '@ory/client';
|
||||
import { AxiosError } from 'axios';
|
||||
import { kratos } from '@/ory/sdk/kratos';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function Error() {
|
||||
|
||||
const [error, setError] = useState<FlowError>();
|
||||
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const id = params.get('id');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
kratos
|
||||
.getFlowError({ id: String(id) })
|
||||
.then(({ data }) => {
|
||||
setError(data);
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
switch (err.response?.status) {
|
||||
case 404:
|
||||
// The error id could not be found. Let's just redirect home!
|
||||
case 403:
|
||||
// The error id could not be fetched due to e.g. a CSRF issue. Let's just redirect home!
|
||||
case 410:
|
||||
// The error id expired. Let's just redirect home!
|
||||
return router.push('/');
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
}, [id, router, error]);
|
||||
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>An error occurred</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
{JSON.stringify(error, null, 2)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/" className="inline-flex space-x-2" passHref>
|
||||
Go back
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
13
authentication/src/app/flow/layout.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { ThemeToggle } from '@/components/themeToggle';
|
||||
import React from 'react';
|
||||
|
||||
export default function FlowLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen items-center justify-center relative space-y-4">
|
||||
<div className="absolute flex items-center space-x-4 top-4 right-4">
|
||||
<ThemeToggle/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
171
authentication/src/app/flow/login/page.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Flow, HandleError, LogoutLink } from '@/ory';
|
||||
import Link from 'next/link';
|
||||
import { LoginFlow, UpdateLoginFlowBody } from '@ory/client';
|
||||
import { kratos } from '@/ory/sdk/kratos';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export default function Login() {
|
||||
|
||||
const [flow, setFlow] = useState<LoginFlow>();
|
||||
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const flowId = params.get('flow') ?? undefined;
|
||||
const aal = params.get('aal') ?? undefined;
|
||||
const refresh = Boolean(params.get('refresh')) ? true : undefined;
|
||||
const returnTo = params.get('return_to') ?? undefined;
|
||||
const loginChallenge = params.get('login_challenge') ?? undefined;
|
||||
|
||||
const onLogout = LogoutLink([aal, refresh]);
|
||||
|
||||
const getFlow = useCallback((flowId: string) => {
|
||||
return kratos
|
||||
.getLoginFlow({ id: String(flowId) })
|
||||
.then(({ data }) => setFlow(data))
|
||||
.catch(handleError);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: AxiosError) => {
|
||||
const handle = HandleError(getFlow, setFlow, '/flow/login', true, router);
|
||||
return handle(error);
|
||||
}, [getFlow]);
|
||||
|
||||
const createFlow = useCallback((aal: string | undefined, refresh: boolean | undefined, returnTo: string | undefined, loginChallenge: string | undefined) => {
|
||||
kratos
|
||||
.createBrowserLoginFlow({ aal, refresh, returnTo, loginChallenge })
|
||||
.then(({ data }) => {
|
||||
setFlow(data);
|
||||
router.push(`?flow=${data.id}`);
|
||||
})
|
||||
.catch(handleError);
|
||||
}, [handleError]);
|
||||
|
||||
const updateFlow = async (body: UpdateLoginFlowBody) => {
|
||||
kratos
|
||||
.updateLoginFlow({
|
||||
flow: String(flow?.id),
|
||||
updateLoginFlowBody: body,
|
||||
})
|
||||
.then(() => {
|
||||
if (flow?.return_to) {
|
||||
window.location.href = flow?.return_to;
|
||||
return;
|
||||
}
|
||||
router.push('/');
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (flow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowId) {
|
||||
getFlow(flowId).then();
|
||||
return;
|
||||
}
|
||||
|
||||
createFlow(aal, refresh, returnTo, loginChallenge);
|
||||
|
||||
}, [flowId, router, aal, refresh, returnTo, createFlow, loginChallenge, getFlow]);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col items-center w-full max-w-sm p-4">
|
||||
<Image className="mt-10 mb-4"
|
||||
width="64"
|
||||
height="64"
|
||||
src="/mt-logo-orange.png"
|
||||
alt="Markus Thielker Intranet"/>
|
||||
<CardHeader className="flex flex-col items-center text-center space-y-4">
|
||||
{
|
||||
flow ?
|
||||
<div className="flex flex-col space-y-4">
|
||||
<CardTitle>{
|
||||
(() => {
|
||||
if (flow?.refresh) {
|
||||
return 'Confirm Action';
|
||||
} else if (flow?.requested_aal === 'aal2') {
|
||||
return 'Two-Factor Authentication';
|
||||
}
|
||||
return 'Welcome';
|
||||
})()}
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xs">
|
||||
Log in to the Intranet to access all locally hosted applications.
|
||||
</CardDescription>
|
||||
</div>
|
||||
:
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Skeleton className="h-6 w-full rounded-md"/>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-full rounded-md"/>
|
||||
<Skeleton className="h-3 w-[250px] rounded-md"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</CardHeader>
|
||||
<CardContent className="w-full">
|
||||
{
|
||||
flow
|
||||
? <Flow flow={flow} onSubmit={updateFlow}/>
|
||||
: (
|
||||
<div className="flex flex-col space-y-4 mt-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-[80px] rounded-md"/>
|
||||
<Skeleton className="h-8 w-full rounded-md"/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-[80px] rounded-md"/>
|
||||
<Skeleton className="h-8 w-full rounded-md"/>
|
||||
</div>
|
||||
<Button disabled>
|
||||
<Skeleton className="h-4 w-[80px] rounded-md"/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</CardContent>
|
||||
{
|
||||
flow?.requested_aal === 'aal2' || flow?.requested_aal === 'aal3' || flow?.refresh ? (
|
||||
<Button onClick={onLogout} variant="link">
|
||||
Log out
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{
|
||||
flow ?
|
||||
<Button variant="link" asChild>
|
||||
<Link href="/flow/recovery" className="text-orange-600" passHref>
|
||||
<span>Forgot your password?</span>
|
||||
</Link>
|
||||
</Button>
|
||||
:
|
||||
<Skeleton className="h-3 w-[180px] rounded-md my-3.5"/>
|
||||
}
|
||||
{
|
||||
flow ?
|
||||
<Button variant="link" asChild disabled={!flow}>
|
||||
<Link href="/flow/registration" className="inline-flex space-x-2" passHref>
|
||||
<span>Create an account</span>
|
||||
</Link>
|
||||
</Button>
|
||||
:
|
||||
<Skeleton className="h-3 w-[180px] rounded-md my-3.5"/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
}
|
127
authentication/src/app/flow/recovery/page.tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Flow, HandleError } from '@/ory';
|
||||
import { RecoveryFlow, UpdateRecoveryFlowBody } from '@ory/client';
|
||||
import { AxiosError } from 'axios';
|
||||
import { kratos } from '@/ory/sdk/kratos';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Image from 'next/image';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export default function Recovery() {
|
||||
|
||||
const [flow, setFlow] = useState<RecoveryFlow>();
|
||||
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const flowId = params.get('flow') ?? undefined;
|
||||
const returnTo = params.get('return_to') ?? undefined;
|
||||
|
||||
const getFlow = useCallback((flowId: string) => {
|
||||
return kratos
|
||||
.getRecoveryFlow({ id: String(flowId) })
|
||||
.then(({ data }) => setFlow(data))
|
||||
.catch(handleError);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: AxiosError) => {
|
||||
const handle = HandleError(getFlow, setFlow, '/flow/recovery', true, router);
|
||||
return handle(error);
|
||||
}, [getFlow]);
|
||||
|
||||
const createFlow = useCallback((returnTo: string | undefined) => {
|
||||
kratos
|
||||
.createBrowserRecoveryFlow({ returnTo })
|
||||
.then(({ data }) => setFlow(data))
|
||||
.catch(handleError)
|
||||
.catch((err: AxiosError) => {
|
||||
if (err.response?.status === 400) {
|
||||
setFlow(err.response?.data as RecoveryFlow);
|
||||
return;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}, [handleError]);
|
||||
|
||||
const updateFlow = async (body: UpdateRecoveryFlowBody) => {
|
||||
kratos
|
||||
.updateRecoveryFlow({
|
||||
flow: String(flow?.id),
|
||||
updateRecoveryFlowBody: body,
|
||||
})
|
||||
.then(({ data }) => setFlow(data))
|
||||
.catch(handleError)
|
||||
.catch((err: AxiosError) => {
|
||||
switch (err.response?.status) {
|
||||
case 400:
|
||||
setFlow(err.response?.data as RecoveryFlow);
|
||||
return;
|
||||
}
|
||||
Promise.reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (flow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowId) {
|
||||
getFlow(flowId);
|
||||
return;
|
||||
}
|
||||
|
||||
createFlow(returnTo);
|
||||
|
||||
}, [flowId, router, returnTo, flow]);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col items-center w-full max-w-sm p-4">
|
||||
<Image className="mt-10 mb-4"
|
||||
width="64"
|
||||
height="64"
|
||||
src="/mt-logo-orange.png"
|
||||
alt="Markus Thielker Intranet"/>
|
||||
<CardHeader className="flex flex-col items-center text-center space-y-4">
|
||||
<CardTitle>
|
||||
Recover your account
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xs">
|
||||
If you forgot your password, you can request an email for resetting it.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full">
|
||||
{
|
||||
flow ?
|
||||
<Flow flow={flow} onSubmit={updateFlow}/>
|
||||
:
|
||||
<div className="flex flex-col space-y-4 mt-3">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-[80px] rounded-md"/>
|
||||
<Skeleton className="h-8 w-full rounded-md"/>
|
||||
</div>
|
||||
<Button disabled>
|
||||
<Skeleton className="h-4 w-[80px] rounded-md"/>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</CardContent>
|
||||
{
|
||||
flow ?
|
||||
<Button variant="link" asChild disabled={!flow}>
|
||||
<Link href="/flow/login" className="inline-flex space-x-2" passHref>
|
||||
Back to login
|
||||
</Link>
|
||||
</Button>
|
||||
:
|
||||
<Skeleton className="h-3 w-[180px] rounded-md my-3.5"/>
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
}
|
134
authentication/src/app/flow/registration/page.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Flow, HandleError } from '@/ory';
|
||||
import Link from 'next/link';
|
||||
import { RegistrationFlow, UpdateRegistrationFlowBody } from '@ory/client';
|
||||
import { AxiosError } from 'axios';
|
||||
import { kratos } from '@/ory/sdk/kratos';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Registration() {
|
||||
|
||||
const [flow, setFlow] = useState<RegistrationFlow>();
|
||||
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const flowId = params.get('flow') ?? undefined;
|
||||
const returnTo = params.get('return_to') ?? undefined;
|
||||
|
||||
const getFlow = useCallback((flowId: string) => {
|
||||
return kratos
|
||||
.getRegistrationFlow({ id: String(flowId) })
|
||||
.then(({ data }) => setFlow(data))
|
||||
.catch(handleError);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: AxiosError) => {
|
||||
const handle = HandleError(getFlow, setFlow, '/flow/registration', true, router);
|
||||
return handle(error);
|
||||
}, [getFlow]);
|
||||
|
||||
const createFlow = useCallback((returnTo: string | undefined) => {
|
||||
kratos
|
||||
.createBrowserRegistrationFlow({ returnTo })
|
||||
.then(({ data }) => {
|
||||
setFlow(data);
|
||||
router.push(`?flow=${data.id}`);
|
||||
})
|
||||
.catch(handleError);
|
||||
}, [handleError]);
|
||||
|
||||
const updateFlow = async (body: UpdateRegistrationFlowBody) => {
|
||||
kratos
|
||||
.updateRegistrationFlow({
|
||||
flow: String(flow?.id),
|
||||
updateRegistrationFlowBody: body,
|
||||
})
|
||||
.then(async ({ data }) => {
|
||||
if (data.continue_with) {
|
||||
for (const item of data.continue_with) {
|
||||
switch (item.action) {
|
||||
case 'show_verification_ui':
|
||||
router.push('/flow/verification?flow=' + item.flow.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
router.push(flow?.return_to || '/');
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (flow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowId) {
|
||||
getFlow(flowId);
|
||||
return;
|
||||
}
|
||||
|
||||
createFlow(returnTo);
|
||||
|
||||
}, [flowId, router, returnTo, flow]);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col items-center w-full max-w-sm p-4">
|
||||
<Image className="mt-10 mb-4"
|
||||
width="64"
|
||||
height="64"
|
||||
src="/mt-logo-orange.png"
|
||||
alt="Markus Thielker Intranet"/>
|
||||
<CardHeader className="flex flex-col items-center text-center space-y-4">
|
||||
<CardTitle>
|
||||
Create account
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xs">
|
||||
Create an account to access the intranet applications.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full">
|
||||
{
|
||||
flow ?
|
||||
<Flow flow={flow} onSubmit={updateFlow}/>
|
||||
:
|
||||
<div className="flex flex-col space-y-4 mt-5">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-[80px] rounded-md"/>
|
||||
<Skeleton className="h-8 w-full rounded-md"/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-[80px] rounded-md"/>
|
||||
<Skeleton className="h-8 w-full rounded-md"/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-[80px] rounded-md"/>
|
||||
<Skeleton className="h-8 w-full rounded-md"/>
|
||||
</div>
|
||||
<Button disabled>
|
||||
<Skeleton className="h-4 w-[80px] rounded-md"/>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</CardContent>
|
||||
{
|
||||
flow ?
|
||||
<Button variant="link" asChild disabled={!flow}>
|
||||
<Link href="/flow/login" className="inline-flex space-x-2" passHref>
|
||||
Log into your account
|
||||
</Link>
|
||||
</Button>
|
||||
:
|
||||
<Skeleton className="h-3 w-[180px] rounded-md my-3.5"/>
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
}
|
126
authentication/src/app/flow/verification/page.tsx
Normal file
|
@ -0,0 +1,126 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Flow, HandleError } from '@/ory';
|
||||
import { UpdateVerificationFlowBody, VerificationFlow } from '@ory/client';
|
||||
import { AxiosError } from 'axios';
|
||||
import { kratos } from '@/ory/sdk/kratos';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Verification() {
|
||||
|
||||
const [flow, setFlow] = useState<VerificationFlow>();
|
||||
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const flowId = params.get('flow') ?? undefined;
|
||||
const returnTo = params.get('return_to') ?? undefined;
|
||||
|
||||
const getFlow = useCallback((flowId: string) => {
|
||||
return kratos
|
||||
.getVerificationFlow({ id: String(flowId) })
|
||||
.then(({ data }) => setFlow(data))
|
||||
.catch((err: AxiosError) => {
|
||||
switch (err.response?.status) {
|
||||
case 410:
|
||||
case 403:
|
||||
return router.push('/flow/verification');
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: AxiosError) => {
|
||||
const handle = HandleError(getFlow, setFlow, '/flow/verification', true, router);
|
||||
return handle(error);
|
||||
}, [getFlow]);
|
||||
|
||||
const createFlow = useCallback((returnTo: string | undefined) => {
|
||||
kratos
|
||||
.createBrowserVerificationFlow({ returnTo })
|
||||
.then(({ data }) => {
|
||||
setFlow(data);
|
||||
router.push(`?flow=${data.id}`);
|
||||
})
|
||||
.catch(handleError);
|
||||
}, [handleError]);
|
||||
|
||||
const updateFlow = async (body: UpdateVerificationFlowBody) => {
|
||||
kratos
|
||||
.updateVerificationFlow({
|
||||
flow: String(flow?.id),
|
||||
updateVerificationFlowBody: body,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
setFlow(data);
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (flow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowId) {
|
||||
getFlow(flowId);
|
||||
return;
|
||||
}
|
||||
|
||||
createFlow(returnTo);
|
||||
|
||||
}, [flowId, router, returnTo, flow]);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col items-center w-full max-w-sm p-4">
|
||||
<Image className="mt-10 mb-4"
|
||||
width="64"
|
||||
height="64"
|
||||
src="/mt-logo-orange.png"
|
||||
alt="Markus Thielker Intranet"/>
|
||||
<CardHeader className="flex flex-col items-center text-center space-y-4">
|
||||
<CardTitle>
|
||||
Verify your account
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xs">
|
||||
{flow?.ui.messages?.map(it => {
|
||||
return <span key={it.id}>{it.text}</span>;
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full">
|
||||
{
|
||||
flow ?
|
||||
<Flow flow={flow} onSubmit={updateFlow} hideGlobalMessages/>
|
||||
:
|
||||
<div className="flex flex-col space-y-4 mt-3">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-3 w-[80px] rounded-md"/>
|
||||
<Skeleton className="h-8 w-full rounded-md"/>
|
||||
</div>
|
||||
<Button disabled>
|
||||
<Skeleton className="h-4 w-[80px] rounded-md"/>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</CardContent>
|
||||
{
|
||||
flow ?
|
||||
<Button variant="link" asChild disabled={!flow}>
|
||||
<Link href="/flow/login" className="inline-flex space-x-2" passHref>
|
||||
Back to login
|
||||
</Link>
|
||||
</Button>
|
||||
:
|
||||
<Skeleton className="h-3 w-[180px] rounded-md my-3.5"/>
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
}
|
62
authentication/src/app/globals.css
Normal file
|
@ -0,0 +1,62 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--primary: 24.6 95% 53.1%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 24.6 95% 53.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
--primary: 20.5 90.2% 48.2%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 20.5 90.2% 48.2%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
63
authentication/src/app/layout.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import type { Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '@/components/themeProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
const APP_NAME = 'Next Ory';
|
||||
const APP_DEFAULT_TITLE = 'Next Ory';
|
||||
const APP_TITLE_TEMPLATE = `%s | ${APP_DEFAULT_TITLE}`;
|
||||
const APP_DESCRIPTION = 'Get started with ORY authentication quickly and easily.';
|
||||
|
||||
export const metadata = {
|
||||
applicationName: APP_NAME,
|
||||
title: {
|
||||
default: APP_DEFAULT_TITLE,
|
||||
template: APP_TITLE_TEMPLATE,
|
||||
},
|
||||
description: APP_DESCRIPTION,
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: APP_DEFAULT_TITLE,
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0B0908',
|
||||
width: 'device-width',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link crossOrigin="use-credentials" rel="manifest" href="/manifest.json"/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon.png"
|
||||
/>
|
||||
</head>
|
||||
<body className={cn(inter.className)}>
|
||||
<main>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster/>
|
||||
</ThemeProvider>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
240
authentication/src/app/page.tsx
Normal file
|
@ -0,0 +1,240 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { SettingsFlow, UpdateSettingsFlowBody } from '@ory/client';
|
||||
import { kratos } from '@/ory/sdk/kratos';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { AxiosError } from 'axios';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Flow, HandleError, LogoutLink } from '@/ory';
|
||||
import { ThemeToggle } from '@/components/themeToggle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
|
||||
const [flow, setFlow] = useState<SettingsFlow>();
|
||||
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const returnTo = params.get('return_to') ?? undefined;
|
||||
const flowId = params.get('flow') ?? undefined;
|
||||
|
||||
const onLogout = LogoutLink();
|
||||
|
||||
const getFlow = useCallback((flowId: string) => {
|
||||
return kratos
|
||||
.getSettingsFlow({ id: String(flowId) })
|
||||
.then(({ data }) => setFlow(data))
|
||||
.catch(handleError);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: AxiosError) => {
|
||||
const handle = HandleError(getFlow, setFlow, '/flow/settings', true, router);
|
||||
return handle(error);
|
||||
}, [getFlow]);
|
||||
|
||||
const createFlow = useCallback((returnTo: string | undefined) => {
|
||||
kratos
|
||||
.createBrowserSettingsFlow({ returnTo })
|
||||
.then(({ data }) => {
|
||||
setFlow(data);
|
||||
router.push(`?flow=${data.id}`);
|
||||
})
|
||||
.catch(handleError);
|
||||
}, [handleError]);
|
||||
|
||||
const updateFlow = async (body: UpdateSettingsFlowBody) => {
|
||||
kratos
|
||||
.updateSettingsFlow({
|
||||
flow: String(flow?.id),
|
||||
updateSettingsFlowBody: body,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
|
||||
// update flow object
|
||||
setFlow(data);
|
||||
|
||||
// show toast for user feedback
|
||||
const message = data.ui.messages?.pop();
|
||||
if (message) {
|
||||
toast.success(message.text);
|
||||
}
|
||||
|
||||
// check if verification is needed
|
||||
if (data.continue_with) {
|
||||
for (const item of data.continue_with) {
|
||||
switch (item.action) {
|
||||
case 'show_verification_ui':
|
||||
router.push('/verification?flow=' + item.flow.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if custom return page was specified
|
||||
if (data.return_to) {
|
||||
window.location.href = data.return_to;
|
||||
return;
|
||||
}
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (flow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowId) {
|
||||
getFlow(flowId).then();
|
||||
return;
|
||||
}
|
||||
|
||||
createFlow(returnTo);
|
||||
|
||||
}, [flowId, router, returnTo, createFlow, getFlow]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen items-center text-3xl relative space-y-4">
|
||||
<div className="absolute flex flex-row w-fit items-center space-x-4 top-4 right-4">
|
||||
<ThemeToggle/>
|
||||
<Button variant="outline" size="icon" onClick={onLogout}>
|
||||
<LogOut className="h-[1.2rem] w-[1.2rem]"/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center space-y-4 w-full max-w-md">
|
||||
<p className="mt-4 py-4 text-4xl">Settings</p>
|
||||
{
|
||||
flow?.ui.nodes.some(({ group }) => group === 'profile') && (
|
||||
<Card className="w-full max-w-md animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Password
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Flow
|
||||
onSubmit={updateFlow}
|
||||
flow={flow}
|
||||
only="profile"
|
||||
hideGlobalMessages/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
{
|
||||
flow?.ui.nodes.some(({ group }) => group === 'password') && (
|
||||
<Card className="w-full max-w-md animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Password
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Flow
|
||||
onSubmit={updateFlow}
|
||||
flow={flow}
|
||||
only="password"
|
||||
hideGlobalMessages/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
{
|
||||
flow?.ui.nodes.some(({ group }) => group === 'totp') && (
|
||||
<Card className="w-full max-w-md animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
MFA
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Flow
|
||||
onSubmit={updateFlow}
|
||||
flow={flow}
|
||||
only="totp"
|
||||
hideGlobalMessages/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
{
|
||||
flow?.ui.nodes.some(({ group }) => group === 'oidc') && (
|
||||
<Card className="w-full max-w-md animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Connect Socials
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Flow
|
||||
onSubmit={updateFlow}
|
||||
flow={flow}
|
||||
only="oidc"
|
||||
hideGlobalMessages/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
{
|
||||
flow?.ui.nodes.some(({ group }) => group === 'link') && (
|
||||
<Card className="w-full max-w-md animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Connect Socials
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Flow
|
||||
onSubmit={updateFlow}
|
||||
flow={flow}
|
||||
only="link"
|
||||
hideGlobalMessages/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
{
|
||||
flow?.ui.nodes.some(({ group }) => group === 'webauthn') && (
|
||||
<Card className="w-full max-w-md animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Connect Socials
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Flow
|
||||
onSubmit={updateFlow}
|
||||
flow={flow}
|
||||
only="webauthn"
|
||||
hideGlobalMessages/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
{
|
||||
flow?.ui.nodes.some(({ group }) => group === 'lookup_secret') && (
|
||||
<Card className="w-full max-w-md animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Recovery Codes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Flow
|
||||
onSubmit={updateFlow}
|
||||
flow={flow}
|
||||
only="lookup_secret"
|
||||
hideGlobalMessages/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
18
authentication/src/app/service-worker.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import type { PrecacheEntry } from '@serwist/precaching';
|
||||
import { installSerwist } from '@serwist/sw';
|
||||
import { defaultCache } from '@serwist/next/worker';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
// Change this attribute's name to your `injectionPoint`.
|
||||
// `injectionPoint` is an InjectManifest option.
|
||||
// See https://serwist.pages.dev/docs/build/inject-manifest/configuring
|
||||
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
|
||||
};
|
||||
|
||||
installSerwist({
|
||||
precacheEntries: self.__SW_MANIFEST,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
navigationPreload: true,
|
||||
runtimeCaching: defaultCache,
|
||||
});
|
120
authentication/src/components/consentForm.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
'use client';
|
||||
|
||||
import { OAuth2ConsentRequest, Session } from '@ory/client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { kratos } from '@/ory';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ConsentFormProps {
|
||||
request: OAuth2ConsentRequest;
|
||||
onAccept: (challenge: string, scopes: string[], remember: boolean) => void;
|
||||
onReject: (challenge: string) => void;
|
||||
}
|
||||
|
||||
export default function ConsentForm(
|
||||
{
|
||||
request,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: ConsentFormProps,
|
||||
) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [session, setSession] = useState<Session | undefined>();
|
||||
|
||||
const [remember, setRemember] = useState<boolean>(false);
|
||||
const [requestedScopes, setRequestedScopes] = useState<string[]>(request.requested_scope ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
kratos
|
||||
.toSession()
|
||||
.then(({ data }) => setSession(data))
|
||||
.catch(() => router.push('/flow/login'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image className="mt-10 mb-4"
|
||||
width="64"
|
||||
height="64"
|
||||
src="/mt-logo-orange.png"
|
||||
alt="Markus Thielker Intranet"/>
|
||||
<CardHeader className="flex items-center text-center space-y-4">
|
||||
<CardTitle>Welcome {session?.identity?.traits.name}</CardTitle>
|
||||
<CardDescription className="max-w-xs">
|
||||
The application {request?.client?.client_name} requests access to the following permissions:
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{request?.requested_scope?.map(scope => (
|
||||
<div key={scope} className="flex flex-row space-x-2">
|
||||
<Checkbox
|
||||
checked={requestedScopes?.includes(scope)}
|
||||
onCheckedChange={() => {
|
||||
if (requestedScopes?.includes(scope)) {
|
||||
setRequestedScopes(requestedScopes.filter(it => it !== scope));
|
||||
} else {
|
||||
setRequestedScopes([...requestedScopes, scope]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label>{scope}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Only grant permissions if you trust this site or app. You don't need to accept all permissions.
|
||||
</CardDescription>
|
||||
<div className="flex flex-row">
|
||||
{request?.client?.policy_uri && (
|
||||
<a href={request?.client.policy_uri} className="text-xs" target="_blank"
|
||||
rel="noreferrer">
|
||||
Privacy Policy
|
||||
</a>
|
||||
)}
|
||||
{request?.client?.tos_uri && (
|
||||
<a href={request?.client.tos_uri} className="text-xs" target="_blank" rel="noreferrer">
|
||||
Terms of Service
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-4"/>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Checkbox
|
||||
checked={remember}
|
||||
onCheckedChange={() => setRemember(!remember)}/>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label>Remember my decision</Label>
|
||||
<Label className="text-xs font-normal">
|
||||
Remember this decision for next time. The application will not be able to ask
|
||||
for
|
||||
additional permissions without your consent.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex w-full space-x-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onReject(request.challenge)}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => onAccept(request.challenge, requestedScopes, remember)}>
|
||||
Accept
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
);
|
||||
}
|
9
authentication/src/components/themeProvider.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
43
authentication/src/components/themeToggle.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function ThemeToggle() {
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun
|
||||
className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"/>
|
||||
<Moon
|
||||
className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"/>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
141
authentication/src/components/ui/alert-dialog.tsx
Normal file
|
@ -0,0 +1,141 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay/>
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
59
authentication/src/components/ui/alert.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
56
authentication/src/components/ui/button.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
79
authentication/src/components/ui/card.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg sm:border bg-card text-card-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
30
authentication/src/components/ui/checkbox.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('flex items-center justify-center text-current')}
|
||||
>
|
||||
<Check className="h-4 w-4"/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
200
authentication/src/components/ui/dropdown-menu.tsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4"/>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
169
authentication/src/components/ui/form.tsx
Normal file
|
@ -0,0 +1,169 @@
|
|||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
26
authentication/src/components/ui/input.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
26
authentication/src/components/ui/label.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
48
authentication/src/components/ui/scroll-area.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar/>
|
||||
<ScrollAreaPrimitive.Corner/>
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border"/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
31
authentication/src/components/ui/separator.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
16
authentication/src/components/ui/skeleton.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import React from 'react';
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
32
authentication/src/components/ui/sonner.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
import React from 'react';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
6
authentication/src/lib/utils.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
199
authentication/src/ory/hooks.tsx
Normal file
|
@ -0,0 +1,199 @@
|
|||
'use client';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import React, { DependencyList, useEffect, useState } from 'react';
|
||||
|
||||
import { kratos } from './sdk/kratos';
|
||||
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
|
||||
|
||||
export const HandleError = (
|
||||
getFlow:
|
||||
| ((flowId: string) => Promise<void | AxiosError>)
|
||||
| undefined = undefined,
|
||||
setFlow: React.Dispatch<React.SetStateAction<any>> | undefined = undefined,
|
||||
defaultNav: string | undefined = undefined,
|
||||
fatalToError = false,
|
||||
router: AppRouterInstance,
|
||||
) => {
|
||||
return async (
|
||||
error: AxiosError<any, unknown>,
|
||||
): Promise<AxiosError | void> => {
|
||||
if (!error.response || error.response?.status === 0) {
|
||||
window.location.href = `/flow/error?error=${encodeURIComponent(
|
||||
JSON.stringify(error.response),
|
||||
)}`;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const responseData = error.response?.data || {};
|
||||
|
||||
switch (error.response?.status) {
|
||||
case 400: {
|
||||
if (responseData.error?.id == 'session_already_available') {
|
||||
router.push('/');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// the request could contain invalid parameters which would set error messages in the flow
|
||||
if (setFlow !== undefined) {
|
||||
console.warn('sdkError 400: update flow data');
|
||||
setFlow(responseData);
|
||||
return Promise.resolve();
|
||||
}
|
||||
break;
|
||||
}
|
||||
// we have no session or the session is invalid
|
||||
case 401: {
|
||||
console.warn('handleError hook 401: Navigate to /login');
|
||||
router.push('/flow/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
case 403: {
|
||||
// the user might have a session, but would require 2FA (Two-Factor Authentication)
|
||||
if (responseData.error?.id === 'session_aal2_required') {
|
||||
router.push('/flow/login?aal2=true');
|
||||
router.refresh();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (
|
||||
responseData.error?.id === 'session_refresh_required' &&
|
||||
responseData.redirect_browser_to
|
||||
) {
|
||||
console.warn(
|
||||
'sdkError 403: Redirect browser to',
|
||||
responseData.redirect_browser_to,
|
||||
);
|
||||
window.location = responseData.redirect_browser_to;
|
||||
return Promise.resolve();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 404: {
|
||||
console.warn('sdkError 404: Navigate to Error');
|
||||
const errorMsg = {
|
||||
data: error.response?.data || error,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: window.location.href,
|
||||
};
|
||||
|
||||
router.push(
|
||||
`/flow/error?error=${encodeURIComponent(JSON.stringify(errorMsg))}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// error.id handling
|
||||
// "self_service_flow_expired"
|
||||
case 410: {
|
||||
if (getFlow !== undefined && responseData.use_flow_id !== undefined) {
|
||||
console.warn('sdkError 410: Update flow');
|
||||
return getFlow(responseData.use_flow_id).catch((error) => {
|
||||
// Something went seriously wrong - log and redirect to defaultNav if possible
|
||||
console.error(error);
|
||||
|
||||
if (defaultNav !== undefined) {
|
||||
router.push(defaultNav);
|
||||
} else {
|
||||
// Rethrow error when can't navigate and let caller handle
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
} else if (defaultNav !== undefined) {
|
||||
console.warn('sdkError 410: Navigate to', defaultNav);
|
||||
router.push(defaultNav);
|
||||
return Promise.resolve();
|
||||
}
|
||||
break;
|
||||
}
|
||||
// we need to parse the response and follow the `redirect_browser_to` URL
|
||||
// this could be when the user needs to perform a 2FA challenge
|
||||
// or passwordless login
|
||||
case 422: {
|
||||
if (responseData.redirect_browser_to !== undefined) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const redirect = new URL(responseData.redirect_browser_to);
|
||||
|
||||
// host name has changed, then change location
|
||||
if (currentUrl.host !== redirect.host) {
|
||||
console.warn('sdkError 422: Host changed redirect');
|
||||
window.location = responseData.redirect_browser_to;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Path has changed
|
||||
if (currentUrl.pathname !== redirect.pathname) {
|
||||
console.warn('sdkError 422: Update path');
|
||||
router.push(redirect.pathname + redirect.search);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// for webauthn we need to reload the flow
|
||||
const flowId = redirect.searchParams.get('flow');
|
||||
|
||||
if (flowId != null && getFlow !== undefined) {
|
||||
// get new flow data based on the flow id in the redirect url
|
||||
console.warn('sdkError 422: Update flow');
|
||||
return getFlow(flowId).catch((error) => {
|
||||
// Something went seriously wrong - log and redirect to defaultNav if possible
|
||||
console.error(error);
|
||||
|
||||
if (defaultNav !== undefined) {
|
||||
router.push(defaultNav);
|
||||
} else {
|
||||
// Rethrow error when can't navigate and let caller handle
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('sdkError 422: Redirect browser to');
|
||||
window.location = responseData.redirect_browser_to;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
if (fatalToError) {
|
||||
console.warn('sdkError: fatal error redirect to /error');
|
||||
router.push('/flow/error?id=' + encodeURI(error.response?.data.error?.id));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
};
|
||||
|
||||
// Returns a function which will log the user out
|
||||
export function LogoutLink(deps?: DependencyList) {
|
||||
|
||||
const [logoutToken, setLogoutToken] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
kratos
|
||||
.createBrowserLogoutFlow()
|
||||
.then(({ data }) => {
|
||||
setLogoutToken(data.logout_token);
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
switch (err.response?.status) {
|
||||
case 401:
|
||||
// do nothing, the user is not logged in
|
||||
return;
|
||||
}
|
||||
|
||||
// Something else happened!
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}, deps);
|
||||
|
||||
return () => {
|
||||
if (logoutToken) {
|
||||
kratos
|
||||
.updateLogoutFlow({ token: logoutToken })
|
||||
.then(() => window.location.href = '/flow/login');
|
||||
}
|
||||
};
|
||||
}
|
3
authentication/src/ory/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './hooks';
|
||||
export * from './ui';
|
||||
export * from './sdk/kratos';
|
15
authentication/src/ory/sdk/hydra/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
'use server';
|
||||
|
||||
import { Configuration, OAuth2Api } from '@ory/client';
|
||||
|
||||
// implemented as a function because of 'use server'
|
||||
export default async function getHydra() {
|
||||
return new OAuth2Api(new Configuration(
|
||||
new Configuration({
|
||||
basePath: process.env.ORY_HYDRA_ADMIN_URL,
|
||||
baseOptions: {
|
||||
withCredentials: true,
|
||||
},
|
||||
}),
|
||||
));
|
||||
}
|
14
authentication/src/ory/sdk/kratos/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { Configuration, FrontendApi } from '@ory/client';
|
||||
|
||||
const kratos = new FrontendApi(
|
||||
new Configuration({
|
||||
basePath: process.env.NEXT_PUBLIC_ORY_KRATOS_URL,
|
||||
baseOptions: {
|
||||
withCredentials: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export { kratos };
|
224
authentication/src/ory/ui/Flow.tsx
Normal file
|
@ -0,0 +1,224 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
LoginFlow,
|
||||
RecoveryFlow,
|
||||
RegistrationFlow,
|
||||
SettingsFlow,
|
||||
UiNode,
|
||||
UpdateLoginFlowBody,
|
||||
UpdateRecoveryFlowBody,
|
||||
UpdateRegistrationFlowBody,
|
||||
UpdateSettingsFlowBody,
|
||||
UpdateVerificationFlowBody,
|
||||
VerificationFlow,
|
||||
} from '@ory/client';
|
||||
import { getNodeId, isUiNodeInputAttributes } from '@ory/integrations/ui';
|
||||
import { Component, FormEvent, MouseEvent } from 'react';
|
||||
|
||||
import { Messages, Node } from '@/ory';
|
||||
|
||||
export type Values = Partial<
|
||||
| UpdateLoginFlowBody
|
||||
| UpdateRegistrationFlowBody
|
||||
| UpdateRecoveryFlowBody
|
||||
| UpdateSettingsFlowBody
|
||||
| UpdateVerificationFlowBody
|
||||
>
|
||||
|
||||
export type Methods =
|
||||
| 'oidc'
|
||||
| 'password'
|
||||
| 'profile'
|
||||
| 'totp'
|
||||
| 'webauthn'
|
||||
| 'passkey'
|
||||
| 'link'
|
||||
| 'lookup_secret'
|
||||
|
||||
export type Props<T> = {
|
||||
// The flow
|
||||
flow?:
|
||||
| LoginFlow
|
||||
| RegistrationFlow
|
||||
| SettingsFlow
|
||||
| VerificationFlow
|
||||
| RecoveryFlow
|
||||
// Only show certain nodes. We will always render the default nodes for CSRF tokens.
|
||||
only?: Methods
|
||||
// Is triggered on submission
|
||||
onSubmit: (values: T) => Promise<void>
|
||||
// Do not show the global messages. Useful when rendering them elsewhere.
|
||||
hideGlobalMessages?: boolean
|
||||
}
|
||||
|
||||
function emptyState<T>() {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
type State<T> = {
|
||||
values: T
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export class Flow<T extends Values> extends Component<Props<T>, State<T>> {
|
||||
constructor(props: Props<T>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
values: emptyState(),
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initializeValues(this.filterNodes());
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
if (prevProps.flow !== this.props.flow) {
|
||||
// Flow has changed, reload the values!
|
||||
this.initializeValues(this.filterNodes());
|
||||
}
|
||||
}
|
||||
|
||||
initializeValues = (nodes: Array<UiNode> = []) => {
|
||||
// Compute the values
|
||||
const values = emptyState<T>();
|
||||
nodes.forEach((node) => {
|
||||
// This only makes sense for text nodes
|
||||
if (isUiNodeInputAttributes(node.attributes)) {
|
||||
if (
|
||||
node.attributes.type === 'button' ||
|
||||
node.attributes.type === 'submit'
|
||||
) {
|
||||
// In order to mimic real HTML forms, we need to skip setting the value
|
||||
// for buttons as the button value will (in normal HTML forms) only trigger
|
||||
// if the user clicks it.
|
||||
return;
|
||||
}
|
||||
values[node.attributes.name as keyof Values] = node.attributes.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Set all the values!
|
||||
this.setState((state) => ({ ...state, values }));
|
||||
};
|
||||
|
||||
filterNodes = (): Array<UiNode> => {
|
||||
const { flow, only } = this.props;
|
||||
if (!flow) {
|
||||
return [];
|
||||
}
|
||||
return flow.ui.nodes.filter(({ group }) => {
|
||||
if (!only) {
|
||||
return true;
|
||||
}
|
||||
return group === 'default' || group === only;
|
||||
});
|
||||
};
|
||||
|
||||
// Handles form submission
|
||||
handleSubmit = (event: FormEvent<HTMLFormElement> | MouseEvent) => {
|
||||
// Prevent all native handlers
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// Prevent double submission!
|
||||
if (this.state.isLoading) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const form = event.currentTarget;
|
||||
|
||||
let body: T | undefined;
|
||||
|
||||
if (form && form instanceof HTMLFormElement) {
|
||||
const formData = new FormData(form);
|
||||
|
||||
// map the entire form data to JSON for the request body
|
||||
body = Object.fromEntries(formData) as T;
|
||||
|
||||
const hasSubmitter = (evt: any): evt is { submitter: HTMLInputElement } =>
|
||||
'submitter' in evt;
|
||||
|
||||
// We need the method specified from the name and value of the submit button.
|
||||
// when multiple submit buttons are present, the clicked one's value is used.
|
||||
if (hasSubmitter(event.nativeEvent)) {
|
||||
const method = event.nativeEvent.submitter;
|
||||
body = {
|
||||
...body,
|
||||
...{ [method.name]: method.value },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
return this.props
|
||||
.onSubmit({ ...body, ...this.state.values })
|
||||
.finally(() => {
|
||||
// We wait for reconciliation and update the state after 50ms
|
||||
// Done submitting - update loading status
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hideGlobalMessages, flow } = this.props;
|
||||
const { values, isLoading } = this.state;
|
||||
|
||||
// Filter the nodes - only show the ones we want
|
||||
const nodes = this.filterNodes();
|
||||
|
||||
if (!flow) {
|
||||
// No flow was set yet? It's probably still loading...
|
||||
//
|
||||
// Nodes have only one element? It is probably just the CSRF Token
|
||||
// and the filter did not match any elements!
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={flow.ui.action}
|
||||
method={flow.ui.method}
|
||||
onSubmit={this.handleSubmit}
|
||||
className="flex flex-col w-full space-y-2"
|
||||
>
|
||||
{!hideGlobalMessages ? <Messages classNames="space-y-2" messages={flow.ui.messages}/> : null}
|
||||
{nodes.map((node, k) => {
|
||||
const id = getNodeId(node) as keyof Values;
|
||||
return (
|
||||
<Node
|
||||
key={`${id}-${k}`}
|
||||
disabled={isLoading}
|
||||
node={node}
|
||||
value={values[id]}
|
||||
dispatchSubmit={this.handleSubmit}
|
||||
setValue={(value) =>
|
||||
new Promise((resolve) => {
|
||||
this.setState(
|
||||
(state) => ({
|
||||
...state,
|
||||
values: {
|
||||
...state.values,
|
||||
[getNodeId(node)]: value,
|
||||
},
|
||||
}),
|
||||
resolve,
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
54
authentication/src/ory/ui/Messages.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { UiText } from '@ory/client';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { JSX } from 'react';
|
||||
import { AlertCircle, AlertOctagon, Check } from 'lucide-react';
|
||||
|
||||
interface MessageProps {
|
||||
message: UiText,
|
||||
}
|
||||
|
||||
export const Message = ({ message }: MessageProps) => {
|
||||
|
||||
let icon: JSX.Element = <></>;
|
||||
switch (message.type) {
|
||||
case 'error':
|
||||
icon = <AlertOctagon className="h-4 w-4"/>;
|
||||
break;
|
||||
case 'success':
|
||||
icon = <Check className="h-4 w-4"/>;
|
||||
break;
|
||||
case 'info':
|
||||
icon = <AlertCircle className="h-4 w-4"/>;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
{icon}
|
||||
<AlertTitle>{message.type.charAt(0).toUpperCase() + message.type.substring(1)}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{message.text}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
interface MessagesProps {
|
||||
messages?: Array<UiText>
|
||||
classNames?: string,
|
||||
}
|
||||
|
||||
export const Messages = ({ messages, classNames }: MessagesProps) => {
|
||||
if (!messages) {
|
||||
// No messages? Do nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
{messages.map((message) => (
|
||||
<Message key={message.id} message={message}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
60
authentication/src/ory/ui/Node.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { UiNode } from '@ory/client';
|
||||
import {
|
||||
isUiNodeAnchorAttributes,
|
||||
isUiNodeImageAttributes,
|
||||
isUiNodeInputAttributes,
|
||||
isUiNodeScriptAttributes,
|
||||
isUiNodeTextAttributes,
|
||||
} from '@ory/integrations/ui';
|
||||
|
||||
import { NodeAnchor } from './NodeAnchor';
|
||||
import { NodeImage, NodeInput, NodeText } from '@/ory';
|
||||
import { NodeScript } from './NodeScript';
|
||||
import { FormDispatcher, ValueSetter } from './helpers';
|
||||
|
||||
interface Props {
|
||||
node: UiNode;
|
||||
disabled: boolean;
|
||||
value: any;
|
||||
setValue: ValueSetter;
|
||||
dispatchSubmit: FormDispatcher;
|
||||
}
|
||||
|
||||
export const Node = ({
|
||||
node,
|
||||
value,
|
||||
setValue,
|
||||
disabled,
|
||||
dispatchSubmit,
|
||||
}: Props) => {
|
||||
if (isUiNodeImageAttributes(node.attributes)) {
|
||||
return <NodeImage node={node} attributes={node.attributes}/>;
|
||||
}
|
||||
|
||||
if (isUiNodeScriptAttributes(node.attributes)) {
|
||||
return <NodeScript node={node} attributes={node.attributes}/>;
|
||||
}
|
||||
|
||||
if (isUiNodeTextAttributes(node.attributes)) {
|
||||
return <NodeText node={node} attributes={node.attributes}/>;
|
||||
}
|
||||
|
||||
if (isUiNodeAnchorAttributes(node.attributes)) {
|
||||
return <NodeAnchor node={node} attributes={node.attributes}/>;
|
||||
}
|
||||
|
||||
if (isUiNodeInputAttributes(node.attributes)) {
|
||||
return (
|
||||
<NodeInput
|
||||
dispatchSubmit={dispatchSubmit}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
node={node}
|
||||
disabled={disabled}
|
||||
attributes={node.attributes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
23
authentication/src/ory/ui/NodeAnchor.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import { UiNode, UiNodeAnchorAttributes } from '@ory/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
node: UiNode;
|
||||
attributes: UiNodeAnchorAttributes;
|
||||
}
|
||||
|
||||
export const NodeAnchor = ({ node, attributes }: Props) => {
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
window.location.href = attributes.href;
|
||||
}}
|
||||
>
|
||||
{attributes.title.text}
|
||||
</Button>
|
||||
);
|
||||
};
|
16
authentication/src/ory/ui/NodeImage.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { UiNode, UiNodeImageAttributes } from '@ory/client';
|
||||
|
||||
interface Props {
|
||||
node: UiNode;
|
||||
attributes: UiNodeImageAttributes;
|
||||
}
|
||||
|
||||
export const NodeImage = ({ node, attributes }: Props) => {
|
||||
return (
|
||||
<img
|
||||
src={attributes.src}
|
||||
width={200}
|
||||
alt={node.meta.label?.text}
|
||||
/>
|
||||
);
|
||||
};
|
29
authentication/src/ory/ui/NodeInput.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { NodeInputButton } from './NodeInputButton';
|
||||
import { NodeInputCheckbox } from './NodeInputCheckbox';
|
||||
import { NodeInputDefault } from './NodeInputDefault';
|
||||
import { NodeInputHidden } from './NodeInputHidden';
|
||||
import { NodeInputSubmit } from './NodeInputSubmit';
|
||||
import { NodeInputProps } from './helpers';
|
||||
|
||||
export function NodeInput<T>(props: NodeInputProps) {
|
||||
const { attributes } = props;
|
||||
|
||||
switch (attributes.type) {
|
||||
case 'hidden':
|
||||
// Render a hidden input field
|
||||
return <NodeInputHidden {...props} />;
|
||||
case 'checkbox':
|
||||
// Render a checkbox. We have one hidden element which is the real value (true/false), and one
|
||||
// display element which is the toggle value (true)!
|
||||
return <NodeInputCheckbox {...props} />;
|
||||
case 'button':
|
||||
// Render a button
|
||||
return <NodeInputButton {...props} />;
|
||||
case 'submit':
|
||||
// Render the submit button
|
||||
return <NodeInputSubmit {...props} />;
|
||||
}
|
||||
|
||||
// Render a generic text input field.
|
||||
return <NodeInputDefault {...props} />;
|
||||
}
|
47
authentication/src/ory/ui/NodeInputButton.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
'use client';
|
||||
|
||||
import { getNodeLabel } from '@ory/integrations/ui';
|
||||
|
||||
import { callWebauthnFunction, NodeInputProps } from './helpers';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React from 'react';
|
||||
|
||||
export function NodeInputButton<T>({
|
||||
node,
|
||||
attributes,
|
||||
setValue,
|
||||
disabled,
|
||||
dispatchSubmit,
|
||||
}: NodeInputProps) {
|
||||
// Some attributes have dynamic JavaScript - this is for example required for WebAuthn.
|
||||
const onClick = (e: React.MouseEvent | React.FormEvent<HTMLFormElement>) => {
|
||||
// This section is only used for WebAuthn. The script is loaded via a <script> node
|
||||
// and the functions are available on the global window level. Unfortunately, there
|
||||
// is currently no better way than executing eval / function here at this moment.
|
||||
//
|
||||
// Please note that we also need to prevent the default action from happening.
|
||||
if (attributes.onclick) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
callWebauthnFunction(attributes.onclick);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(attributes.value).then(() => dispatchSubmit(e));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
name={attributes.name}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
}}
|
||||
value={attributes.value || ''}
|
||||
disabled={attributes.disabled || disabled}
|
||||
>
|
||||
{getNodeLabel(node)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
36
authentication/src/ory/ui/NodeInputCheckbox.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
'use client';
|
||||
|
||||
import { NodeInputProps } from './helpers';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
export function NodeInputCheckbox<T>(
|
||||
{
|
||||
node,
|
||||
attributes,
|
||||
setValue,
|
||||
disabled,
|
||||
}: NodeInputProps,
|
||||
) {
|
||||
|
||||
const state =
|
||||
node.messages.find(({ type }) => type === 'error') ? 'error' : undefined;
|
||||
|
||||
// Render a checkbox.
|
||||
return (
|
||||
<div className={`inline-flex space-x-2 items-center ${state ? 'text-yellow-500' : undefined}`}>
|
||||
<Checkbox
|
||||
id={attributes.name}
|
||||
name={attributes.name}
|
||||
defaultChecked={attributes.value}
|
||||
onCheckedChange={(e) => setValue(e)}
|
||||
disabled={attributes.disabled || disabled}
|
||||
className={`my-2 ${state ? 'border-yellow-500' : undefined}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={attributes.name}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
dangerouslySetInnerHTML={{ __html: node.meta.label?.text || '' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
48
authentication/src/ory/ui/NodeInputDefault.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import { NodeInputProps } from './helpers';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
export function NodeInputDefault<T>(props: NodeInputProps) {
|
||||
const { node, attributes, value = '', setValue, disabled } = props;
|
||||
|
||||
// Some attributes have dynamic JavaScript - this is for example required for WebAuthn.
|
||||
const onClick = () => {
|
||||
// This section is only used for WebAuthn. The script is loaded via a <script> node
|
||||
// and the functions are available on the global window level. Unfortunately, there
|
||||
// is currently no better way than executing eval / function here at this moment.
|
||||
if (attributes.onclick) {
|
||||
const run = new Function(attributes.onclick);
|
||||
run();
|
||||
}
|
||||
};
|
||||
|
||||
const state =
|
||||
node.messages.find(({ type }) => type === 'error') ? 'error' : undefined;
|
||||
|
||||
// Render a generic text input field.
|
||||
return (
|
||||
<div>
|
||||
<Label className={state ? 'text-yellow-500' : undefined}>{node.meta.label?.text}</Label>
|
||||
<Input
|
||||
title={node.meta.label?.text}
|
||||
onClick={onClick}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={node.meta.label?.text}
|
||||
type={attributes.type}
|
||||
name={attributes.name}
|
||||
value={value}
|
||||
autoComplete={attributes.autocomplete}
|
||||
disabled={attributes.disabled || disabled}
|
||||
/>
|
||||
{node.messages.map(({ text, id }, k) => (
|
||||
<Label className="text-yellow-500 inline-flex space-x-2 items-center mt-1.5" key={`${id}-${k}`}>
|
||||
<AlertTriangle className="h-4 w-4"/>
|
||||
<span>{text}</span>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
15
authentication/src/ory/ui/NodeInputHidden.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { NodeInputProps, useOnload } from './helpers';
|
||||
|
||||
export function NodeInputHidden<T>({ attributes }: NodeInputProps) {
|
||||
useOnload(attributes as any);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={attributes.type}
|
||||
name={attributes.name}
|
||||
value={attributes.value || 'true'}
|
||||
/>
|
||||
);
|
||||
}
|
24
authentication/src/ory/ui/NodeInputSubmit.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import { getNodeLabel } from '@ory/integrations/ui';
|
||||
|
||||
import { NodeInputProps } from './helpers';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function NodeInputSubmit<T>(
|
||||
{
|
||||
node,
|
||||
attributes,
|
||||
disabled,
|
||||
}: NodeInputProps,
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
name={attributes.name}
|
||||
value={attributes.value || ''}
|
||||
disabled={attributes.disabled || disabled}
|
||||
>
|
||||
{getNodeLabel(node)}
|
||||
</Button>
|
||||
);
|
||||
}
|
32
authentication/src/ory/ui/NodeScript.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
'use client';
|
||||
|
||||
import { UiNode, UiNodeScriptAttributes } from '@ory/client';
|
||||
import { HTMLAttributeReferrerPolicy, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
node: UiNode;
|
||||
attributes: UiNodeScriptAttributes;
|
||||
}
|
||||
|
||||
export const NodeScript = ({ attributes }: Props) => {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
|
||||
script.async = true;
|
||||
script.src = attributes.src;
|
||||
script.async = attributes.async;
|
||||
script.crossOrigin = attributes.crossorigin;
|
||||
script.integrity = attributes.integrity;
|
||||
script.referrerPolicy =
|
||||
attributes.referrerpolicy as HTMLAttributeReferrerPolicy;
|
||||
script.type = attributes.type;
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, [attributes]);
|
||||
|
||||
return null;
|
||||
};
|
42
authentication/src/ory/ui/NodeText.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { UiNode, UiNodeTextAttributes, UiText } from '@ory/client';
|
||||
|
||||
interface Props {
|
||||
node: UiNode;
|
||||
attributes: UiNodeTextAttributes;
|
||||
}
|
||||
|
||||
const Content = ({ node, attributes }: Props) => {
|
||||
switch (attributes.text.id) {
|
||||
case 1050015:
|
||||
// This text node contains lookup secrets. Let's make them a bit more beautiful!
|
||||
const secrets = (attributes.text.context as any).secrets.map(
|
||||
(text: UiText, k: number) => (
|
||||
<div key={k} className="text-sm">
|
||||
<code>{text.id === 1050014 ? 'Used' : text.text}</code>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<div className="row">{secrets}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 rounded-md border flex-wrap text-sm">
|
||||
{attributes.text.text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NodeText = ({ node, attributes }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<p className="text-lg">
|
||||
{node.meta?.label?.text}
|
||||
</p>
|
||||
<Content node={node} attributes={attributes}/>
|
||||
</>
|
||||
);
|
||||
};
|
44
authentication/src/ory/ui/helpers.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { UiNode, UiNodeInputAttributes } from '@ory/client';
|
||||
import { FormEvent, MouseEvent, useEffect } from 'react';
|
||||
|
||||
export type ValueSetter = (
|
||||
value: string | number | boolean | undefined,
|
||||
) => Promise<void>
|
||||
|
||||
export type FormDispatcher = (
|
||||
e: FormEvent<HTMLFormElement> | MouseEvent,
|
||||
) => Promise<void>
|
||||
|
||||
export interface NodeInputProps {
|
||||
node: UiNode;
|
||||
attributes: UiNodeInputAttributes;
|
||||
value: any;
|
||||
disabled: boolean;
|
||||
dispatchSubmit: FormDispatcher;
|
||||
setValue: ValueSetter;
|
||||
}
|
||||
|
||||
export const useOnload = (attributes: { onload?: string }) => {
|
||||
useEffect(() => {
|
||||
if (attributes.onload) {
|
||||
const intervalHandle = callWebauthnFunction(attributes.onload);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalHandle);
|
||||
};
|
||||
}
|
||||
}, [attributes]);
|
||||
};
|
||||
|
||||
export const callWebauthnFunction = (functionBody: string) => {
|
||||
const run = new Function(functionBody);
|
||||
|
||||
const intervalHandle = window.setInterval(() => {
|
||||
if ((window as any).__oryWebAuthnInitialized) {
|
||||
run();
|
||||
window.clearInterval(intervalHandle);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return intervalHandle;
|
||||
};
|
6
authentication/src/ory/ui/index.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from './Flow';
|
||||
export * from './Messages';
|
||||
export * from './Node';
|
||||
export * from './NodeImage';
|
||||
export * from './NodeInput';
|
||||
export * from './NodeText';
|
85
authentication/tailwind.config.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: '',
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'fadeIn': 'fadeIn 0.25s ease-in-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
43
authentication/tsconfig.json
Normal file
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext",
|
||||
"webworker"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
"@serwist/next/typings"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
40
docker/README.md
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Starting as a container
|
||||
|
||||
Starting this project in a container makes testing it really easy. \
|
||||
|
||||
```bash
|
||||
# move to the environment you want to start (here development)
|
||||
cd ory-dev
|
||||
|
||||
# use the example environment for development
|
||||
cp .env.example .env
|
||||
|
||||
# execute the docker compose file
|
||||
docker compose up -d
|
||||
|
||||
# test the consent flow
|
||||
sh ./hydra-test-consent.sh
|
||||
```
|
||||
|
||||
These commands will start up multiple containers in the background.
|
||||
Then continue with starting the authentication UI development server as described in the authentication README.
|
||||
|
||||
## Services and Ports
|
||||
|
||||
As mentioned above, the docker command starts multiple container which interact with each other.
|
||||
Here you see a list of all services and their exposed ports.
|
||||
These ports are only exposed to the host machine.
|
||||
If you start up the environment on a remote server, you will need to tunnel the ports.
|
||||
|
||||
| Service | Port (Public) | Description |
|
||||
|----------------|---------------|---------------------------------------------------------------------------|
|
||||
| Console | 4411 (✗) | Admin dashboard for Kratos data management (soon) |
|
||||
| Authentication | 3000 (✗) | User interface for authentication and account management (no docker yet) |
|
||||
| ORY Kratos | 4433 (✗) | User management system handling users and self-service flows (Public API) |
|
||||
| ORY Kratos | 4434 (✗) | User management system handling users and self-service flows (Admin API) |
|
||||
| Mailslurper | 4436 (✗) | Mock mailing server (Dashboard) |
|
||||
| Mailslurper | 4437 (✗) | Mock mailing server (API) |
|
||||
| ORY Hydra | 4444 (✗) | OAuth2 and OIDC server connected to Kratos (Public API) |
|
||||
| ORY Hydra | 4445 (✗) | OAuth2 and OIDC server connected to Kratos (Admin API) |
|
||||
| ORY Hydra | 5555 (✗) | Hydra test application to test the consent flow |
|
||||
| Postgres DB | 4455 (✗) | Postgres database for storing user data |
|
2
docker/ory-dev/.env
Normal file
|
@ -0,0 +1,2 @@
|
|||
# The URL of ORY Hydras admin API
|
||||
HYDRA_ADMIN_API=http://hydra:4445
|
2
docker/ory-dev/.env.example
Normal file
|
@ -0,0 +1,2 @@
|
|||
# The URL of ORY Hydras admin API
|
||||
HYDRA_ADMIN_API=http://hydra:4445
|
1
docker/ory-dev/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
postgres-data/
|
114
docker/ory-dev/docker-compose.yaml
Normal file
|
@ -0,0 +1,114 @@
|
|||
services:
|
||||
|
||||
ory-kratos-migrate:
|
||||
container_name: ory-kratos-migrate
|
||||
image: oryd/kratos:v1.1.0
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ./ory/kratos:/etc/config/kratos
|
||||
- ory-kratos-data:/home/ory
|
||||
- ory-kratos-data:/var/lib/sqlite
|
||||
command: -c /etc/config/kratos/kratos.yaml migrate sql -e --yes
|
||||
depends_on:
|
||||
ory-postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
ory-kratos:
|
||||
container_name: ory-kratos
|
||||
image: oryd/kratos:v1.1.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 127.0.0.1:4433:4433 # public
|
||||
- 127.0.0.1:4434:4434 # admin
|
||||
volumes:
|
||||
- ./ory/kratos:/etc/config/kratos
|
||||
- ory-kratos-data:/home/ory
|
||||
- ory-kratos-data:/var/lib/sqlite
|
||||
command: serve -c /etc/config/kratos/kratos.yaml --dev --watch-courier
|
||||
depends_on:
|
||||
ory-kratos-migrate:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
ory-hydra-migrate:
|
||||
container_name: ory-hydra-migrate
|
||||
image: oryd/hydra:v2.2.0
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ./ory/hydra:/etc/config/hydra
|
||||
- ory-hydra-data:/home/ory
|
||||
- ory-hydra-data:/var/lib/sqlite
|
||||
command: migrate -c /etc/config/hydra/hydra.yaml sql -e --yes
|
||||
depends_on:
|
||||
ory-postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
ory-hydra:
|
||||
container_name: ory-hydra
|
||||
image: oryd/hydra:v2.2.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 127.0.0.1:4444:4444 # public
|
||||
- 127.0.0.1:4445:4445 # admin
|
||||
- 127.0.0.1:5555:5555 # Port for hydra token user
|
||||
volumes:
|
||||
- ./ory/hydra:/etc/config/hydra
|
||||
- ory-hydra-data:/home/ory
|
||||
- ory-hydra-data:/var/lib/sqlite
|
||||
command: serve -c /etc/config/hydra/hydra.yaml all --dev
|
||||
depends_on:
|
||||
ory-hydra-migrate:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
ory-mailslurper:
|
||||
container_name: ory-mailslurper
|
||||
image: oryd/mailslurper:latest-smtps
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 127.0.0.1:4436:4436
|
||||
- 127.0.0.1:4437:4437
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
ory-postgres:
|
||||
container_name: ory-postgres
|
||||
image: postgres:15.2
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- 127.0.0.1:5432:5432
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- ory-postgres-data:/var/lib/postgresql/data
|
||||
- ./postgres:/docker-entrypoint-initdb.d/
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
|
||||
volumes:
|
||||
ory-kratos-data:
|
||||
ory-hydra-data:
|
||||
ory-postgres-data:
|
25
docker/ory-dev/hydra-create-client.sh
Normal file
|
@ -0,0 +1,25 @@
|
|||
# this script adds a new oath client using the
|
||||
# Ory Hydra CLI and writes the client id and
|
||||
# client secret to the command line.
|
||||
|
||||
read -r -p "Did you modify the script according to your needs? (y/N)? " answer
|
||||
if [ answer != "y" && anser != "Y" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# it is likely you will have to set different redirect-uris
|
||||
# depending on the application you are trying to connect.
|
||||
code_client=$(docker compose exec ory-hydra \
|
||||
hydra create client \
|
||||
--endpoint http://localhost:4445 \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code,id_token \
|
||||
--format json \
|
||||
--scope openid --scope offline \
|
||||
--redirect-uri http://localhost:8080/login/oauth2/code/hydra)
|
||||
|
||||
code_client_id=$(echo $code_client | jq -r '.client_id')
|
||||
code_client_secret=$(echo $code_client | jq -r '.client_secret')
|
||||
|
||||
echo "Client ID:" $code_client_id
|
||||
echo "Client Secret:" $code_client_secret
|
23
docker/ory-dev/hydra-test-consent.sh
Normal file
|
@ -0,0 +1,23 @@
|
|||
# this script adds a new oath client using the
|
||||
# Ory Hydra CLI and uses the client to start
|
||||
# the Ory Hydra test application.
|
||||
|
||||
code_client=$(docker compose exec ory-hydra \
|
||||
hydra create client \
|
||||
--endpoint http://localhost:4445 \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code,id_token \
|
||||
--format json \
|
||||
--scope openid --scope offline \
|
||||
--redirect-uri http://127.0.0.1:5555/callback)
|
||||
|
||||
code_client_id=$(echo $code_client | jq -r '.client_id')
|
||||
code_client_secret=$(echo $code_client | jq -r '.client_secret')
|
||||
|
||||
docker compose exec ory-hydra \
|
||||
hydra perform authorization-code \
|
||||
--client-id $code_client_id \
|
||||
--client-secret $code_client_secret \
|
||||
--endpoint http://localhost:4444/ \
|
||||
--port 5555 \
|
||||
--scope openid --scope offline
|
88
docker/ory-dev/ory/hydra/hydra.yaml
Normal file
|
@ -0,0 +1,88 @@
|
|||
#
|
||||
# Documentation: https://www.ory.sh/docs/hydra/reference/configuration
|
||||
# Configuration UI: https://www.ory.sh/docs/hydra/reference/configuration-editor
|
||||
#
|
||||
|
||||
#
|
||||
# Configure the Hydra logging
|
||||
#
|
||||
log:
|
||||
level: info
|
||||
format: text
|
||||
leak_sensitive_values: true
|
||||
|
||||
|
||||
#
|
||||
# Configure the datasource. Alternative for development purposes is 'memory' (not persistent!)
|
||||
#
|
||||
dsn: postgres://postgres:postgres@ory-postgres:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
|
||||
|
||||
|
||||
#
|
||||
# Configure the base URLs for the public and admin API.
|
||||
# The public URL is used in emails for verification links.
|
||||
#
|
||||
serve:
|
||||
|
||||
public:
|
||||
cors:
|
||||
enabled: true
|
||||
debug: true
|
||||
allowed_origins:
|
||||
- http://localhost:3000
|
||||
|
||||
admin:
|
||||
cors:
|
||||
enabled: true
|
||||
debug: true
|
||||
allowed_origins:
|
||||
- http://localhost:3000
|
||||
|
||||
cookies:
|
||||
domain: http://localhost
|
||||
same_site_mode: Lax
|
||||
secure: false
|
||||
paths:
|
||||
session: /
|
||||
names:
|
||||
consent_csrf: ory_hydra_consent_csrf
|
||||
session: ory_hydra_session
|
||||
login_csrf: ory_hydra_login_csrf
|
||||
|
||||
|
||||
urls:
|
||||
|
||||
consent: http://localhost:3000/flow/consent
|
||||
login: http://localhost:3000/flow/login
|
||||
logout: http://localhost:3000/flow/logout
|
||||
post_logout_redirect: http://localhost:3000
|
||||
|
||||
identity_provider:
|
||||
url: http://kratos:4434
|
||||
|
||||
self:
|
||||
public: http://localhost:4444
|
||||
admin: http://localhost:4445
|
||||
issuer: http://localhost:4444
|
||||
|
||||
|
||||
#
|
||||
# Configure secrets and key rotation.
|
||||
# Documentation: https://www.ory.sh/docs/hydra/self-hosted/secrets-key-rotation
|
||||
#
|
||||
secrets:
|
||||
system:
|
||||
- youReallyNeedToChangeThis
|
||||
|
||||
|
||||
#
|
||||
# Configure the OAuth2 clients.
|
||||
# Documentation: https://www.ory.sh/docs/hydra/next/clients
|
||||
#
|
||||
oidc:
|
||||
subject_identifiers:
|
||||
supported_types:
|
||||
- pairwise
|
||||
- public
|
||||
pairwise:
|
||||
salt: youReallyNeedToChangeThis
|
13
docker/ory-dev/ory/kratos/discord.data-mapper.jsonnet
Normal file
|
@ -0,0 +1,13 @@
|
|||
|
||||
local claims = std.extVar('claims');
|
||||
|
||||
{
|
||||
identity: {
|
||||
traits: {
|
||||
[if 'email' in claims && claims.email_verified then 'email' else null]: claims.email,
|
||||
[if 'nickname' in claims then 'username' else null]: claims.nickname,
|
||||
[if 'nickname' in claims then 'name' else null]: claims.nickname,
|
||||
},
|
||||
metadata_public: claims,
|
||||
},
|
||||
}
|
43
docker/ory-dev/ory/kratos/identity.schema.json
Normal file
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "User",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "Email",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"webauthn": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"recovery": {
|
||||
"via": "email"
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
135
docker/ory-dev/ory/kratos/kratos.yaml
Normal file
|
@ -0,0 +1,135 @@
|
|||
#
|
||||
# Documentation: https://www.ory.sh/docs/kratos/reference/configuration
|
||||
# Configuration UI: https://www.ory.sh/docs/kratos/reference/configuration-editor
|
||||
#
|
||||
|
||||
#
|
||||
# Configure the Kratos logging
|
||||
#
|
||||
log:
|
||||
level: info
|
||||
format: text
|
||||
leak_sensitive_values: true
|
||||
|
||||
#
|
||||
# Configure the datasource. Alternative for development purposes is 'memory' (not persistent!)
|
||||
#
|
||||
dsn: postgres://postgres:postgres@ory-postgres:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4
|
||||
|
||||
#
|
||||
# Configure the base URLs for the public and admin API.
|
||||
# The public URL is used in emails for verification links.
|
||||
#
|
||||
serve:
|
||||
public:
|
||||
base_url: http://localhost:4433
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- http://localhost:3000
|
||||
admin:
|
||||
base_url: http://localhost:4434
|
||||
|
||||
#
|
||||
# Configure the session cookie.
|
||||
#
|
||||
cookies:
|
||||
domain: http://localhost
|
||||
path: /
|
||||
same_site: Lax
|
||||
|
||||
#
|
||||
# Configure the self-service flows.session.
|
||||
# Probably most interesting are ui urls, return urls and hooks.session.
|
||||
# You can also activate authentication methods.
|
||||
#
|
||||
selfservice:
|
||||
default_browser_return_url: http://localhost:3000
|
||||
allowed_return_urls:
|
||||
- http://localhost:3000
|
||||
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
totp:
|
||||
enabled: true
|
||||
config:
|
||||
issuer: ORY Template
|
||||
lookup_secret:
|
||||
enabled: true
|
||||
|
||||
flows:
|
||||
error:
|
||||
ui_url: http://localhost:3000/flow/error
|
||||
|
||||
settings:
|
||||
required_aal: highest_available
|
||||
ui_url: http://localhost:3000
|
||||
|
||||
recovery:
|
||||
enabled: true
|
||||
ui_url: http://localhost:3000/flow/recovery
|
||||
|
||||
verification:
|
||||
enabled: true
|
||||
ui_url: http://localhost:3000/flow/verification
|
||||
|
||||
login:
|
||||
ui_url: http://localhost:3000/flow/login
|
||||
lifespan: 10m
|
||||
after:
|
||||
hooks:
|
||||
- hook: require_verified_address
|
||||
|
||||
registration:
|
||||
lifespan: 10m
|
||||
ui_url: http://localhost:3000/flow/registration
|
||||
# after:
|
||||
# default_browser_return_url: http://localhost:3000
|
||||
# password:
|
||||
# hooks:
|
||||
# - hook: session # automatically sign-in after registration
|
||||
|
||||
#
|
||||
# Configure connection to hydra for oauth2 and oidc.
|
||||
# If set, the login and registration flows will handle the Ory OAuth 2.0 & OpenID `login_challenge` query parameter to serve as an OpenID Connect Provider.
|
||||
#
|
||||
oauth2_provider:
|
||||
override_return_to: false
|
||||
url: http://ory-hydra:4445
|
||||
|
||||
#
|
||||
# Configure secrets and key rotation.
|
||||
# Documentation: https://www.ory.sh/docs/kratos/guides/secret-key-rotation
|
||||
#
|
||||
secrets:
|
||||
cookie:
|
||||
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
|
||||
cipher:
|
||||
- 32-LONG-SECRET-NOT-SECURE-AT-ALL
|
||||
|
||||
ciphers:
|
||||
algorithm: xchacha20-poly1305
|
||||
|
||||
hashers:
|
||||
algorithm: bcrypt
|
||||
bcrypt:
|
||||
cost: 8
|
||||
|
||||
#
|
||||
# The delivered identity schema shows how to use the schema system.
|
||||
# Documentation: https://www.ory.sh/docs/kratos/manage-identities/identity-schema
|
||||
#
|
||||
identity:
|
||||
default_schema_id: default
|
||||
schemas:
|
||||
- id: default
|
||||
url: file:///etc/config/kratos/identity.schema.json
|
||||
|
||||
#
|
||||
# Configure the mailing service.
|
||||
# Documentation: https://www.ory.sh/docs/kratos/self-hosted/mail-courier-selfhosted
|
||||
#
|
||||
courier:
|
||||
smtp:
|
||||
connection_uri: smtps://test:test@ory-mailslurper:1025/?skip_ssl_verify=true
|
11
docker/ory-dev/ory/kratos/twitch.data-mapper.jsonnet
Normal file
|
@ -0,0 +1,11 @@
|
|||
|
||||
local claims = std.extVar('claims');
|
||||
|
||||
{
|
||||
identity: {
|
||||
traits: {
|
||||
[if 'email' in claims && claims.email_verified then 'email' else null]: claims.email
|
||||
},
|
||||
metadata_public: claims,
|
||||
},
|
||||
}
|
5
docker/ory-dev/postgres/init.sql
Normal file
|
@ -0,0 +1,5 @@
|
|||
CREATE DATABASE kratos;
|
||||
GRANT ALL PRIVILEGES ON DATABASE kratos TO postgres;
|
||||
|
||||
CREATE DATABASE hydra;
|
||||
GRANT ALL PRIVILEGES ON DATABASE hydra TO postgres;
|
BIN
documentation/.img/login-dark.png
Normal file
After Width: | Height: | Size: 623 KiB |
BIN
documentation/.img/registration-dark.png
Normal file
After Width: | Height: | Size: 624 KiB |