Refactor site checklist, add secret generator

This commit is contained in:
Sam Becker 2023-09-06 18:05:29 -05:00
parent ed019be284
commit 33ec20d709
15 changed files with 323 additions and 201 deletions

View File

@ -9,6 +9,7 @@
"nextjs",
"qaub",
"skippable",
"sonner",
"thephotoblog",
"trpc",
"WRHGZC",

View File

@ -33,6 +33,7 @@
"react-icons": "^4.10.1",
"react-spinners": "^0.13.8",
"short-uuid": "^4.2.2",
"sonner": "^0.7.0",
"tailwindcss": "3.3.3",
"ts-exif-parser": "^0.2.2",
"typescript": "5.2.2"

12
pnpm-lock.yaml generated
View File

@ -29,6 +29,7 @@ specifiers:
react-icons: ^4.10.1
react-spinners: ^0.13.8
short-uuid: ^4.2.2
sonner: ^0.7.0
tailwindcss: 3.3.3
ts-exif-parser: ^0.2.2
typescript: 5.2.2
@ -59,6 +60,7 @@ dependencies:
react-icons: 4.10.1_react@18.2.0
react-spinners: 0.13.8_biqbaboplfbrettd7655fr4n2y
short-uuid: 4.2.2
sonner: 0.7.0_biqbaboplfbrettd7655fr4n2y
tailwindcss: 3.3.3
ts-exif-parser: 0.2.2
typescript: 5.2.2
@ -3189,6 +3191,16 @@ packages:
engines: {node: '>=8'}
dev: false
/sonner/0.7.0_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-vAlXCrE6/183yt64ktIUnPv85RmAPYiicl5z35fDDFhWRIUpg7N62TsiIbHjwGuxbVJu/5hYlh92HHImsS27dA==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
/source-map-js/1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}

View File

@ -1,13 +1,12 @@
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid';
import { SITE_CHECKLIST_STATUS } from '@/site';
import SiteChecklist from '@/site/SiteChecklist';
export default function ChecklistPage() {
export default async function ChecklistPage() {
return (
<SiteGrid
contentMain={<InfoBlock>
<SiteChecklist {...SITE_CHECKLIST_STATUS} />
<SiteChecklist />
</InfoBlock>}
/>
);

View File

@ -6,6 +6,7 @@ import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
import StateProvider from '@/state/AppStateProvider';
import ThemeProviderClient from '@/site/ThemeProviderClient';
import Nav from '@/components/Nav';
import ToasterClient from '@/components/ToasterClient';
import '../site/globals.css';
@ -76,6 +77,7 @@ export default function RootLayout({
</StateProvider>
<Analytics />
</main>
<ToasterClient />
</ThemeProviderClient>
</body>
</html>

View File

@ -10,37 +10,36 @@ export default function SignInForm() {
const [password, setPassword] = useState('');
return (
<InfoBlock
className="space-y-8"
padding="normal"
>
<div className="space-y-4">
<FieldSet
id="email"
label="Admin Email"
value={email}
onChange={setEmail}
/>
<FieldSet
id="password"
label="Admin Password"
value={password}
onChange={setPassword}
type="password"
/>
<InfoBlock>
<div className="space-y-8">
<div className="space-y-4">
<FieldSet
id="email"
label="Admin Email"
value={email}
onChange={setEmail}
/>
<FieldSet
id="password"
label="Admin Password"
value={password}
onChange={setPassword}
type="password"
/>
</div>
<button
onClick={() => signIn(
'credentials',
{
email,
password,
callbackUrl: '/admin/photos',
},
)}
>
Sign in
</button>
</div>
<button
onClick={() => signIn(
'credentials',
{
email,
password,
callbackUrl: '/admin/photos',
},
)}
>
Sign in
</button>
</InfoBlock>
);
}

View File

@ -55,3 +55,8 @@ export const {
signIn: '/sign-in',
},
});
export const generateAuthSecret = () => fetch(
'https://generate-secret.vercel.app/32',
{ cache: 'no-cache' },
).then(res => res.text());

View File

@ -4,27 +4,33 @@ import { ReactNode } from 'react';
export default function InfoBlock({
children,
className,
padding = 'loose',
padding = 'normal',
}: {
children: ReactNode
className?: string
padding?: 'loose' | 'normal';
padding?: 'loose' | 'normal' | 'tight';
} ) {
const getPaddingClasses = () => {
switch (padding) {
case 'loose': return 'p-4 md:p-24';
case 'normal': return 'p-4 md:p-8';
case 'tight': return 'py-2 px-3';
}
};
return (
<div className={cc(
'flex flex-col items-center justify-center',
'px-8 rounded-lg',
padding === 'loose' ? 'py-24' : 'py-8',
'border',
'rounded-lg border',
'bg-gray-50 border-gray-200',
'dark:bg-gray-900/40 dark:border-gray-800',
'text-center',
getPaddingClasses(),
className,
)}>
<div className={cc(
'flex flex-col items-center justify-center',
'space-y-4',
'text-gray-500 dark:text-gray-400',
className,
)}>
{children}
</div>

View File

@ -0,0 +1,34 @@
'use client';
import { cc } from '@/utility/css';
import Spinner from './Spinner';
export default function IconButton({
children,
onClick,
isLoading,
}: {
children: React.ReactNode
onClick?: () => void
isLoading?: boolean
}) {
return (
<span className="min-w-[1.1rem]">
{!isLoading
? <span
onClick={onClick}
className={cc(
onClick !== undefined && 'cursor-pointer',
'active:opacity-50',
)}
>
{children}
</span>
: <span className={cc(
'inline-block translate-x-[2px] translate-y-[1px]',
)}>
<Spinner size={12} />
</span>}
</span>
);
}

View File

@ -0,0 +1,11 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster } from 'sonner';
export default function ToasterClient() {
const { theme } = useTheme();
return (
<Toaster theme={theme as 'system' | 'light' | 'dark'} />
);
}

View File

@ -1,13 +1,13 @@
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid';
import { SITE_CHECKLIST_STATUS } from '@/site';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
import SiteChecklist from '@/site/SiteChecklist';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { HiOutlinePhotograph } from 'react-icons/hi';
export default function PhotosEmptyState() {
const showChecklist = Object.values(SITE_CHECKLIST_STATUS).some(v => !v);
const showChecklist = Object.values(CONFIG_CHECKLIST_STATUS).some(v => !v);
return (
<SiteGrid
@ -21,13 +21,11 @@ export default function PhotosEmptyState() {
'font-bold text-2xl',
'text-gray-700 dark:text-gray-200',
)}>
{showChecklist
? 'Finish Setup'
: 'Welcome!'}
{showChecklist ? 'Finish Setup' : 'Welcome!'}
</div>
{showChecklist
? <SiteChecklist {...SITE_CHECKLIST_STATUS} />
: <div className="max-w-md leading-[1.7]">
? <SiteChecklist />
: <div className="max-w-md leading-[1.7] text-center">
<div className="mb-2">
1. Visit
{' '}

View File

@ -1,144 +1,13 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { cc } from '@/utility/css';
import SiteChecklistRow from './SiteChecklistRow';
import { FiExternalLink } from 'react-icons/fi';
export default function SiteChecklist({
hasTitle,
hasDomain,
hasPostgres,
hasBlob,
hasAuth,
showRefreshButton,
}: {
hasTitle: boolean
hasDomain: boolean
hasPostgres: boolean
hasBlob: boolean
hasAuth: boolean
showRefreshButton?: boolean
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const refreshSetupStatus = () => {
startTransition(router.refresh);
};
const renderLink = (href: string, text: string, external = true) =>
<>
<a {...{
href,
...external && { target: '_blank', rel: 'noopener noreferrer' },
className: cc(
'underline hover:no-underline',
),
}}>
{text}
</a>
{external &&
<>
&nbsp;
<FiExternalLink
size={14}
className='inline translate-y-[-1.5px]'
/>
</>}
</>;
const renderEnvVar = (variables: string[]) =>
<div className="py-1 space-y-1">
{variables.map(variable =>
<div key={variable}>
<span className={cc(
'rounded-sm',
'bg-gray-100 text-gray-500',
'dark:bg-gray-800 dark:text-gray-400',
)}>
`{variable}`
</span>
</div>)}
</div>;
import { generateAuthSecret } from '@/auth';
import SiteChecklistClient from './SiteChecklistClient';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
export default async function SiteChecklist() {
const secret = await generateAuthSecret();
return (
<div className={cc(
'text-sm',
'max-w-xl',
'bg-white dark:bg-black',
'dark:text-gray-400',
'border border-gray-200 dark:border-gray-800 rounded-md',
'divide-y divide-gray-200 dark:divide-gray-800',
)}>
<SiteChecklistRow
title="Add title"
status={hasTitle}
isPending={isPending}
>
Store in environment variable:
{renderEnvVar(['NEXT_PUBLIC_SITE_TITLE'])}
</SiteChecklistRow>
<SiteChecklistRow
title="Add domain"
status={hasDomain}
isPending={isPending}
>
Store in environment variable:
{renderEnvVar(['NEXT_PUBLIC_SITE_DOMAIN'])}
</SiteChecklistRow>
<SiteChecklistRow
title="Setup database"
status={hasPostgres}
isPending={isPending}
>
{renderLink(
'https://vercel.com/docs/storage/vercel-postgres/quickstart',
'Create Vercel Postgres store',
)}
{' '}
and connect to project
</SiteChecklistRow>
<SiteChecklistRow
title="Setup blob store"
status={hasBlob}
isPending={isPending}
>
{renderLink(
'https://vercel.com/docs/storage/vercel-blob/quickstart',
'Create Vercel Blob store',
)}
{' '}
and connect to project
</SiteChecklistRow>
<SiteChecklistRow
title="Setup auth"
status={hasAuth}
isPending={isPending}
>
{renderLink(
'https://clerk.com/docs/quickstarts/setup-clerk',
'Create Clerk account',
)}
{' '}
and add environment variables:
{renderEnvVar([
'NEXT_PUBLIC_CLERK_SIGN_IN_URL',
'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
'CLERK_SECRET_KEY',
'CLERK_ADMIN_USER_ID',
])}
</SiteChecklistRow>
<div className="py-4 space-y-4">
<div className="px-8 text-gray-400">
Changes to environment variables require a redeploy
or reboot of local dev server
</div>
{showRefreshButton &&
<button onClick={refreshSetupStatus}>
Check
</button>}
</div>
</div>
<SiteChecklistClient {...{
...CONFIG_CHECKLIST_STATUS,
secret,
}} />
);
}

View File

@ -0,0 +1,186 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { cc } from '@/utility/css';
import SiteChecklistRow from './SiteChecklistRow';
import { FiExternalLink } from 'react-icons/fi';
import { BiCopy, BiRefresh } from 'react-icons/bi';
import IconButton from '@/components/LoaderIcon';
import { toast } from 'sonner';
import InfoBlock from '@/components/InfoBlock';
export default function SiteChecklistClient({
hasTitle,
hasDomain,
hasPostgres,
hasBlob,
hasAuth,
hasAdminUser,
showRefreshButton,
secret,
}: {
hasTitle: boolean
hasDomain: boolean
hasPostgres: boolean
hasBlob: boolean
hasAuth: boolean
hasAdminUser: boolean
showRefreshButton?: boolean
secret: string
}) {
const router = useRouter();
const [isPendingPage, startTransitionPage] = useTransition();
const [isPendingSecret, startTransitionSecret] = useTransition();
const refreshPage = () => {
startTransitionPage(router.refresh);
};
const refreshSecret = () => {
startTransitionSecret(router.refresh);
};
const renderLink = (href: string, text: string, external = true) =>
<>
<a {...{
href,
...external && { target: '_blank', rel: 'noopener noreferrer' },
className: cc(
'underline hover:no-underline',
),
}}>
{text}
</a>
{external &&
<>
&nbsp;
<FiExternalLink
size={14}
className='inline translate-y-[-1.5px]'
/>
</>}
</>;
const renderEnvVar = (variable: string) =>
<div key={variable}>
<span className={cc(
'rounded-sm',
'bg-gray-100 text-gray-500',
'dark:bg-gray-800 dark:text-gray-400',
)}>
`{variable}`
</span>
</div>;
const renderEnvVars = (variables: string[]) =>
<div className="py-1 space-y-1">
{variables.map(renderEnvVar)}
</div>;
return (
<div className="text-sm max-w-xl space-y-4">
<div className={cc(
'bg-white dark:bg-black',
'dark:text-gray-400',
'border border-gray-200 dark:border-gray-800 rounded-md',
'divide-y divide-gray-200 dark:divide-gray-800',
)}>
<SiteChecklistRow
title="Add title"
status={hasTitle}
isPending={isPendingPage}
>
Store in environment variable:
{renderEnvVars(['NEXT_PUBLIC_SITE_TITLE'])}
</SiteChecklistRow>
<SiteChecklistRow
title="Add domain"
status={hasDomain}
isPending={isPendingPage}
>
Store in environment variable:
{renderEnvVars(['NEXT_PUBLIC_SITE_DOMAIN'])}
</SiteChecklistRow>
<SiteChecklistRow
title="Setup database"
status={hasPostgres}
isPending={isPendingPage}
>
{renderLink(
'https://vercel.com/docs/storage/vercel-postgres/quickstart',
'Create Vercel Postgres store',
)}
{' '}
and connect to project
</SiteChecklistRow>
<SiteChecklistRow
title="Setup blob store"
status={hasBlob}
isPending={isPendingPage}
>
{renderLink(
'https://vercel.com/docs/storage/vercel-blob/quickstart',
'Create Vercel Blob store',
)}
{' '}
and connect to project
</SiteChecklistRow>
<SiteChecklistRow
title="Setup auth"
status={hasAuth}
isPending={isPendingPage}
>
Store auth secret in environment variable:
<InfoBlock className="my-1.5" padding="tight">
<div className="flex items-center gap-4">
<span>{secret}</span>
<div className="flex items-center gap-1">
<IconButton
onClick={() => {
navigator.clipboard.writeText(secret);
toast('Secret copied to clipboard', {
duration: 4000,
});
}}
>
<BiCopy size={16} />
</IconButton>
<IconButton
onClick={refreshSecret}
isLoading={isPendingSecret}
>
<BiRefresh size={18} />
</IconButton>
</div>
</div>
</InfoBlock>
{renderEnvVars(['AUTH_SECRET'])}
</SiteChecklistRow>
<SiteChecklistRow
title="Setup admin user"
status={hasAdminUser}
isPending={isPendingPage}
>
Store admin email/password
{' '}
in environment variables:
{renderEnvVars([
'ADMIN_EMAIL',
'ADMIN_PASSWORD',
])}
</SiteChecklistRow>
{showRefreshButton &&
<div className="py-4 space-y-4">
<button onClick={refreshPage}>
Check
</button>
</div>}
</div>
<div className="px-10 text-gray-500">
Changes to environment variables require a redeploy
or reboot of local dev server
</div>
</div>
);
}

View File

@ -11,3 +11,15 @@ export const SITE_DESCRIPTION = process.env.NEXT_PUBLIC_SITE_DESCRIPTION
export const BASE_URL = process.env.NODE_ENV === 'production'
? `https://${SITE_DOMAIN}`
: 'http://localhost:3000';
export const CONFIG_CHECKLIST_STATUS = {
hasTitle: (process.env.NEXT_PUBLIC_SITE_TITLE ?? '').length > 0,
hasDomain: (process.env.NEXT_PUBLIC_SITE_DOMAIN ?? '').length > 0,
hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
hasBlob: (process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0,
hasAuth: (process.env.AUTH_SECRET ?? '').length > 0,
hasAdminUser: (
(process.env.ADMIN_EMAIL ?? '').length > 0 &&
(process.env.ADMIN_PASSWORD ?? '').length > 0
),
};

View File

@ -23,16 +23,3 @@ const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
export const BLOB_BASE_URL =
`https://${STORE_ID}.public.blob.vercel-storage.com`;
export const SITE_CHECKLIST_STATUS = {
hasTitle: (process.env.NEXT_PUBLIC_SITE_TITLE ?? '').length > 0,
hasDomain: (process.env.NEXT_PUBLIC_SITE_DOMAIN ?? '').length > 0,
hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
hasBlob: (process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0,
hasAuth: (
(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ?? '').length > 0 &&
(process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL ?? '').length > 0 &&
(process.env.CLERK_SECRET_KEY ?? '').length > 0 &&
(process.env.CLERK_ADMIN_USER_ID ?? '').length > 0
),
};