Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,320 changes: 1,320 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@aws-sdk/client-cognito-identity-provider": "^3.699.0",
"@biomejs/biome": "^1.9.4",
"@storybook/addon-essentials": "^8",
"@storybook/addon-interactions": "^8",
Expand All @@ -30,10 +31,13 @@
"@storybook/react-vite": "^8",
"@storybook/test": "^8",
"@types/luxon": "^3.4.2",
"@types/md5": "^2.3.5",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^4.2.1",
"luxon": "^3.5.0",
"md5": "^2.3.0",
"oidc-client-ts": "^3.1.0",
"ra-data-local-forage": "^5",
"ra-input-rich-text": "^5.4.0",
"react": "^18",
Expand Down
52 changes: 38 additions & 14 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import localForageDataProvider from 'ra-data-local-forage';
import { useEffect, useState } from 'react';
import { Admin, type DataProvider, Resource } from 'react-admin';
import { Route } from 'react-router-dom';
import { Admin, CustomRoutes, type DataProvider, Resource } from 'react-admin';
import { Route, RouterProvider, createBrowserRouter } from 'react-router-dom';

import streamPlans, { StreamPlansCalendar } from './resources/stream_plans';

import defaultData from '../defaultData.json';
import authProvider from './authProvider';
import i18nProvider from './i18nProvider';
import LoginPage from './pages/LoginPage';
import ProfilePage from './pages/ProfilePage';
import Layout from './ra/Layout';

function App() {
const [dataProvider, setDataProvider] = useState<DataProvider | null>(null);
Expand All @@ -28,18 +32,38 @@ function App() {
// hide the admin until the data provider is ready
if (!dataProvider) return <p>Loading...</p>;

return (
<Admin dataProvider={dataProvider} i18nProvider={i18nProvider}>
<Resource name="stream_plans" {...streamPlans}>
<Route path="calendar" element={<StreamPlansCalendar />} />
<Route path="calendar/:targetDate" element={<StreamPlansCalendar />} />
<Route
path="calendar/:targetDate/:view"
element={<StreamPlansCalendar />}
/>
</Resource>
</Admin>
);
const router = createBrowserRouter([
{
path: '*',
element: (
<Admin
dataProvider={dataProvider}
i18nProvider={i18nProvider}
authProvider={authProvider}
layout={Layout}
loginPage={LoginPage}
>
<Resource name="stream_plans" {...streamPlans}>
<Route path="calendar" element={<StreamPlansCalendar />} />
<Route
path="calendar/:targetDate"
element={<StreamPlansCalendar />}
/>
<Route
path="calendar/:targetDate/:view"
element={<StreamPlansCalendar />}
/>
</Resource>

<CustomRoutes>
<Route path="/profile" element={<ProfilePage />} />
</CustomRoutes>
</Admin>
),
},
]);

return <RouterProvider router={router} />;
}

export default App;
71 changes: 71 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
CognitoIdentityProviderClient,
CompleteWebAuthnRegistrationCommand,
StartWebAuthnRegistrationCommand,
} from '@aws-sdk/client-cognito-identity-provider';
import { UserManager } from 'oidc-client-ts';

const {
VITE_AWS_REGION: region,
VITE_COGNITO_CLIENT_ID: clientId,
VITE_COGNITO_USER_POOL_ID: userPoolId,
VITE_COGNITO_DOMAIN: domain,
VITE_REDIRECT_URI: redirectUri,
VITE_LOGOUT_URI: logoutUri,
} = import.meta.env;

const client = new CognitoIdentityProviderClient({ region });

export const userManager = new UserManager({
authority: `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`,
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email aws.cognito.signin.user.admin',
});

export async function signoutRedirect() {
return `https://${domain}/logout?client_id=${clientId}&logout_uri=${encodeURIComponent(logoutUri)}`;
}

export async function createPasskey(accessToken: string) {
console.assert(window.navigator.credentials, 'WebAuthn not supported');

const startCommand = new StartWebAuthnRegistrationCommand({
AccessToken: accessToken,
});

const startCommandResult = await client.send(startCommand);

if (!startCommandResult.CredentialCreationOptions) {
throw new Error('CredentialCreationOptions not found');
}

const createCredentialOptions =
// biome-ignore lint/suspicious/noExplicitAny: parseCreationOptionsFromJSON exists but is not typed
(PublicKeyCredential as any).parseCreationOptionsFromJSON(
startCommandResult.CredentialCreationOptions,
);

console.log('createCredentialOptions2', createCredentialOptions);

const credential = await window.navigator.credentials.create({
publicKey: createCredentialOptions,
});

console.log('Credential', credential);

if (!credential) {
throw new Error('Credential not found');
}

const completeCommand = new CompleteWebAuthnRegistrationCommand({
AccessToken: accessToken,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
Credential: credential as any,
});

const completeCommandResult = await client.send(completeCommand);

console.log('CompleteCommandResult', completeCommandResult);
}
55 changes: 55 additions & 0 deletions src/authProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { AuthProvider } from 'react-admin';

import { signoutRedirect, userManager } from './auth';
import gravatar from './gravitar';

const authProvider: AuthProvider = {
async login({ returnTo }) {
await userManager.signinRedirect({
redirect_uri: returnTo,
});
},
async logout() {
return signoutRedirect();
},
async checkError(responseError) {
console.log('checkError', responseError);

// TODO

return Promise.resolve();
},
async checkAuth() {
return userManager.getUser().then((user) => {
if (!user) {
throw new Error();
}
});
},
async getIdentity() {
const user = await userManager.getUser();

if (!user) {
return {
id: '',
fullName: '',
avatar: '',
email: '',
accessToken: null,
};
}

return {
id: user.profile.sub,
fullName: user.profile.name,
avatar: gravatar(user.profile.email, user.profile.name),
email: user.profile.email,
accessToken: user.access_token,
};
},
async handleCallback() {
await userManager.signinRedirectCallback();
},
};

export default authProvider;
32 changes: 32 additions & 0 deletions src/gravitar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import md5 from 'md5';

export default function gravatar(
email: string | undefined,
name: string | undefined,
size = 100,
) {
const uiAvatarUrl = new URL('https://ui-avatars.com/api');
if (name) {
uiAvatarUrl.searchParams.append('name', name || '');
uiAvatarUrl.searchParams.append('size', size.toString());
}

if (email) {
const hash = md5(email);

const gravitarUrl = new URL(`https://www.gravatar.com/avatar/${hash}`);
gravitarUrl.searchParams.append('s', size.toString());

if (name) {
gravitarUrl.searchParams.append('d', uiAvatarUrl.toString());
}

return gravitarUrl.toString();
}

if (name) {
return uiAvatarUrl.toString();
}

return '';
}
49 changes: 49 additions & 0 deletions src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Box, Card, CardContent, Typography } from '@mui/material';
import { useEffect } from 'react';
import { useCheckAuth, useLogin, useNotify, useTranslate } from 'react-admin';
import { useNavigate } from 'react-router-dom';

const cardStyle = {
maxWidth: '50em',
margin: '6em auto',
padding: 20,
textAlign: 'center',
};

const MyLoginPage = () => {
const login = useLogin();
const notify = useNotify();
const translate = useTranslate();
const checkAuth = useCheckAuth();
const navigate = useNavigate();

// check if the user is already authenticated
useEffect(() => {
checkAuth({}, false)
// if the user is authenticated, redirect to the home page
.then(() => navigate('/'))
// if the user is not authenticated, do nothing
.catch(() => {
login({}).catch(() => notify('ra.auth.sign_in_error'));
});
}, [checkAuth, navigate, login, notify]);

return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
>
<Card sx={cardStyle}>
<CardContent>
<Typography variant="h4" gutterBottom>
{translate('gt.login.redirecting', { _: 'Redirecting...' })}
</Typography>
</CardContent>
</Card>
</Box>
);
};

export default MyLoginPage;
Loading