Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

管理者ユーザー画面を作成する #9

Open
wants to merge 8 commits into
base: scripts
Choose a base branch
from
38 changes: 38 additions & 0 deletions app/components/forms/auth/SignInWithGoogleForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { IconBrandGoogle } from '@tabler/icons-react';
import { useCallback, useState } from 'react';
import { LoadingOverlayButton } from '~/components/elements/LoadingOverlayButton';
import { signInWithGoogle } from '~/utils/firebase/auth';
import { notify } from '~/utils/mantine/notifications';

export const SignInWithGoogleForm = ({
onSubmit,
}: {
onSubmit?: () => void;
}) => {
const [loading, setLoading] = useState(false);
const handleClick = useCallback(async () => {
try {
setLoading(true);
await signInWithGoogle();
notify.info({ message: 'サインインしました' });
onSubmit?.();
} catch (error) {
console.error(error);
notify.error({ message: 'サインインに失敗しました' });
} finally {
setLoading(false);
}
}, [onSubmit, setLoading]);

return (
<LoadingOverlayButton
loading={loading}
onClick={handleClick}
variant='default'
leftSection={<IconBrandGoogle />}
aria-label='Sign in with Google'
>
Sign in with Google
</LoadingOverlayButton>
);
};
3 changes: 3 additions & 0 deletions app/components/pages/admin/AdminRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const AdminRoot = () => {
return <div>AdminRoot</div>;
};
10 changes: 10 additions & 0 deletions app/components/pages/admin/AdminSignIn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Center } from '@mantine/core';
import { SignInWithGoogleForm } from '~/components/forms/auth/SignInWithGoogleForm';

export const AdminSignIn = () => {
return (
<Center py='lg'>
<SignInWithGoogleForm />
</Center>
);
};
15 changes: 15 additions & 0 deletions app/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useCallback } from 'react';
import { useAuth } from '~/contexts/auth';

export const usePermissions = () => {
const { role } = useAuth();
const validatePathPermission = useCallback(
(path: string) => {
if (/^\/admin\/?$/.test(path) && role === 'admin') return true;
return false;
},
[role],
);

return { validatePathPermission };
};
54 changes: 54 additions & 0 deletions app/layouts/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useNavigate, useLocation } from '@remix-run/react';
import { useEffect } from 'react';
import {
ResponsiveLayout,
useResponsiveLayoutContext,
} from '~/components/layouts/ResponsiveLayout';
import { LoadingScreen } from '~/components/screens/LoadingScreen';
import { withAuth, useAuth } from '~/contexts/auth';
import { usePermissions } from '~/hooks/usePermissions';
import { AccountMenu } from './_components/AccountMenu';
import { AdminNavMenu } from './_components/AdminNavMenu';
import { AdminTitle } from './_components/AdminTitle';
import type { ReactNode } from 'react';

export const AdminLayout = withAuth(
({ children }: { children: ReactNode }) => {
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname + location.search;
const { signedIn } = useAuth();
const { validatePathPermission } = usePermissions();
const validatedPathPermission = validatePathPermission(currentPath);

useEffect(() => {
if (signedIn === false)
navigate(
{
pathname: '/admin/sign-in',
search: `?redirect=${currentPath}`,
},
{ replace: true },
);
if (signedIn === true && !validatedPathPermission)
navigate('/', { replace: true });
}, [signedIn, validatedPathPermission, currentPath, navigate]);

if (!signedIn) return <LoadingScreen />;
if (!validatedPathPermission) return <LoadingScreen />;

return (
<ResponsiveLayout
header={{
title: <AdminTitle />,
props: { bg: 'black', c: 'white' },
}}
navbar={{ navMenu: <AdminNavMenu />, accountMenu: <AccountMenu /> }}
>
{children}
</ResponsiveLayout>
);
},
);

export const useAdminLayout = useResponsiveLayoutContext;
30 changes: 23 additions & 7 deletions app/layouts/AuthLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,48 @@
import { useNavigate, useSearchParams } from '@remix-run/react';
import { useEffect } from 'react';
import {
useLocation,
useNavigate,
useSearchParams,
} from '@remix-run/react';
import { useEffect, useMemo } from 'react';
import {
ResponsiveLayout,
useResponsiveLayoutContext,
} from '~/components/layouts/ResponsiveLayout';
import { LoadingScreen } from '~/components/screens/LoadingScreen';
import { useAuth, withAuth } from '~/contexts/auth';
import { AdminTitle } from './_components/AdminTitle';
import { Title } from './_components/Title';
import type { ReactNode } from 'react';

export const AuthLayout = withAuth(
({ children }: { children: ReactNode }) => {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
const { signedIn } = useAuth();
const isAdminPage = useMemo(
() => location.pathname.startsWith('/admin'),
[location.pathname],
);
const header = useMemo(
() =>
isAdminPage
? {
title: <AdminTitle />,
props: { bg: 'black', c: 'white' },
}
: { title: <Title /> },
[isAdminPage],
);

useEffect(() => {
if (signedIn === true) navigate(redirect || '/', { replace: true });
}, [signedIn, navigate, redirect]);

if (signedIn !== false) return <LoadingScreen />;

return (
<ResponsiveLayout header={{ title: <Title /> }}>
{children}
</ResponsiveLayout>
);
return <ResponsiveLayout header={header}>{children}</ResponsiveLayout>;
},
);

Expand Down
3 changes: 3 additions & 0 deletions app/layouts/_components/AdminNavMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const AdminNavMenu = () => {
return <div>AdminNavMenu</div>;
};
5 changes: 5 additions & 0 deletions app/layouts/_components/AdminTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Text } from '@mantine/core';

export const AdminTitle = () => {
return <Text fw={500}>Firebaseチュートリアル (admin)</Text>;
};
5 changes: 5 additions & 0 deletions app/routes/_auth.admin.sign-in._index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminSignIn } from '~/components/pages/admin/AdminSignIn';

export default function AdminSignInPage() {
return <AdminSignIn />;
}
5 changes: 5 additions & 0 deletions app/routes/admin._index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminRoot } from '~/components/pages/admin/AdminRoot';

export default function AdminRootPage() {
return <AdminRoot />;
}
18 changes: 18 additions & 0 deletions app/routes/admin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Outlet } from '@remix-run/react';
import { AdminLayout as _AdminLayout } from '~/layouts/AdminLayout';
import type { MetaFunction } from '@remix-run/react';

export const meta: MetaFunction = () => {
return [
{ title: 'Firebaseチュートリアル (admin)' },
{ name: 'description', content: 'Firebaseチュートリアル (admin)' },
];
};

export default function AdminLayout() {
return (
<_AdminLayout>
<Outlet />
</_AdminLayout>
);
}
19 changes: 19 additions & 0 deletions functions/src/auth/beforeUserSignedIn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
HttpsError,
beforeUserSignedIn as _beforeUserSignedIn,
logger,
} from '../utils/firebase/functions.js';

export const beforeUserSignedIn = _beforeUserSignedIn(async (event) => {
const user = event.data;
if (
user.customClaims?.role !== 'admin' &&
event.eventType ===
'providers/cloud.auth/eventTypes/user.beforeSignIn:google.com'
) {
logger.warn('Unauthorized user tried to sign in with Google account', {
user,
});
throw new HttpsError('unauthenticated', 'Unauthorized user.');
}
});
2 changes: 2 additions & 0 deletions functions/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { beforeUserCreated } from './beforeUserCreated.js';
import { beforeUserSignedIn } from './beforeUserSignedIn.js';

export const auth = {
beforeUserCreated,
beforeUserSignedIn,
};
24 changes: 23 additions & 1 deletion functions/src/utils/firebase/functions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { logger } from 'firebase-functions/v2';
import {
beforeUserCreated as _beforeUserCreated,
beforeUserSignedIn as _beforeUserSignedIn,
HttpsError,
} from 'firebase-functions/v2/identity';
import type { BlockingOptions } from 'firebase-functions/v2/identity';
Expand All @@ -22,4 +23,25 @@ const beforeUserCreated = (
);
};

export { defaultRegion, logger, HttpsError, beforeUserCreated };
type BeforeUserSignedInHandler = Parameters<typeof _beforeUserSignedIn>[1];
const beforeUserSignedIn = (
optsOrHandler: BlockingOptions | BeforeUserSignedInHandler,
_handler?: BeforeUserSignedInHandler,
) => {
const handler = _handler ?? (optsOrHandler as BeforeUserSignedInHandler);
return _beforeUserSignedIn(
{
region: defaultRegion,
...(_handler ? (optsOrHandler as BlockingOptions) : {}),
},
handler,
);
};

export {
defaultRegion,
logger,
HttpsError,
beforeUserCreated,
beforeUserSignedIn,
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@remix-run/node": "^2.13.1",
"@remix-run/react": "^2.13.1",
"@sonicgarden/react-fire-hooks": "^1.1.2",
"@tabler/icons-react": "^3.19.0",
"firebase": "^10.14.1",
"isbot": "^4",
"react": "^18.2.0",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions scripts/src/createAdminUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { program } from 'commander';
import yesno from 'yesno';
import { createAdminUser } from './models/user.js';
import { runWithFirebaseApp } from './utils/firebase.js';

type Props = {
email: string;
};

runWithFirebaseApp(async () => {
const { email } = program
.option('-e, --email <email>')
.parse(process.argv)
.opts() as Props;

if (!email) {
console.error(program.outputHelp());
return;
}

console.info('');
console.info('[email]', email);
console.info('');
const ok = await yesno({
question: '管理者アカウントを作成しますか?(y/n)',
});
if (!ok) {
console.info('キャンセルしました');
return;
}

await createAdminUser(email);
console.info('管理者アカウントを作成しました');
});
Loading