feat: add logout and dropdown

This commit is contained in:
Aarnav Tale 2024-03-30 02:44:06 -04:00
parent 3f65c7e610
commit 50bcfa9c04
No known key found for this signature in database
7 changed files with 144 additions and 19 deletions

View File

@ -1,6 +1,9 @@
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UsersIcon } from '@heroicons/react/24/outline' import { Menu, Transition } from '@headlessui/react'
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node' import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Outlet, useLoaderData, useRouteError } from '@remix-run/react' import { Form, Outlet, useLoaderData, useRouteError } from '@remix-run/react'
import clsx from 'clsx'
import { Fragment } from 'react/jsx-runtime'
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error'
import TabLink from '~/components/TabLink' import TabLink from '~/components/TabLink'
@ -34,7 +37,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
} }
const context = await getContext() const context = await getContext()
return context return {
...context,
user: session.get('user')
}
} }
export default function Layout() { export default function Layout() {
@ -43,9 +49,72 @@ export default function Layout() {
<> <>
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'> <header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
<nav className='container mx-auto'> <nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'> <div className='flex items-center justify-between mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/> <div className='flex items-center gap-x-2'>
<h1 className='text-2xl'>Headplane</h1> <CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Download
</a>
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
GitHub
</a>
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<div className='relative'>
<Menu>
<Menu.Button>
<UserCircleIcon className='w-8 h-8'/>
</Menu.Button>
<Transition
as={Fragment}
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
>
<Menu.Items className={clsx(
'absolute right-0 w-36 mt-2 rounded-md',
'text-gray-700 dark:text-gray-300',
'bg-white dark:bg-zinc-800 text-right',
'overflow-hidden',
'border border-gray-200 dark:border-zinc-500',
'divide-y divide-gray-200 dark:divide-zinc-500'
)}
>
<Menu.Item>
{() => (
<div className='px-4 py-2'>
<p>{data.user?.name}</p>
<p>{data.user?.email}</p>
</div>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<Form method='POST' action='/logout'>
<button
type='submit'
className={clsx(
'px-4 py-2 w-full text-right',
active ? 'bg-gray-200 dark:bg-zinc-500' : ''
)}
>
Logout
</button>
</Form>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</div> </div>
<div className='flex items-center gap-x-4'> <div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/> <TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>

View File

@ -1,8 +1,9 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node' import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Link, useActionData, useLoaderData } from '@remix-run/react' import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react' import { useMemo } from 'react'
import Code from '~/components/Code' import Code from '~/components/Code'
import { type Key } from '~/types'
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc' import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions' import { commitSession, getSession } from '~/utils/sessions'
@ -46,26 +47,47 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData() const formData = await request.formData()
const oidcStart = String(formData.get('oidc-start'))
if (oidcStart) {
const issuer = process.env.OIDC_ISSUER
const id = process.env.OIDC_CLIENT_ID
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(issuer!, id!, request)
}
const apiKey = String(formData.get('api-key')) const apiKey = String(formData.get('api-key'))
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
// Test the API key // Test the API key
try { try {
await pull('v1/apikey', apiKey) const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey)
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix))
if (!key) {
throw new Error('Invalid API key')
}
const expiry = new Date(key.expiration)
const expiresIn = expiry.getTime() - Date.now()
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24)
session.set('hsApiKey', apiKey)
session.set('user', {
name: key.prefix,
email: `${expiresDays} days`
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
} catch (error) { } catch (error) {
console.error(error) console.error(error)
return json({ return json({
error: 'Invalid API key' error: 'Invalid API key'
}) })
} }
session.set('hsApiKey', apiKey)
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
} }
export default function Page() { export default function Page() {
@ -118,11 +140,12 @@ export default function Page() {
</div> </div>
) : undefined} ) : undefined}
{data.oidc ? ( {data.oidc ? (
<Link to='/oidc/start'> <Form method='POST'>
<button className='bg-gray-800 text-white rounded-md p-2 w-full' type='button'> <input type='hidden' name='oidc-start' value='true'/>
<button className='bg-gray-800 text-white rounded-md p-2 w-full' type='submit'>
Login with SSO Login with SSO
</button> </button>
</Link> </Form>
) : undefined} ) : undefined}
</div> </div>
</div> </div>

15
app/routes/logout.tsx Normal file
View File

@ -0,0 +1,15 @@
import { type ActionFunctionArgs, redirect } from '@remix-run/node'
import { destroySession, getSession } from '~/utils/sessions'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const returnTo = new URL(request.url).pathname
return redirect(`/login?returnTo=${returnTo}`, {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}

7
app/types/Key.ts Normal file
View File

@ -0,0 +1,7 @@
export type Key = {
id: string;
prefix: string;
expiration: string;
createdAt: Date;
lastSeen: Date;
}

View File

@ -1,3 +1,4 @@
export * from './Key'
export * from './Machine' export * from './Machine'
export * from './Route' export * from './Route'
export * from './User' export * from './User'

View File

@ -126,6 +126,11 @@ export async function finishOidc(issuer: string, client: string, secret: string,
}) })
session.set('hsApiKey', keyResponse.apiKey) session.set('hsApiKey', keyResponse.apiKey)
session.set('user', {
name: claims.name ? String(claims.name) : 'Anonymous',
email: claims.email ? String(claims.email) : undefined
})
return redirect('/machines', { return redirect('/machines', {
headers: { headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -5,6 +5,10 @@ type SessionData = {
authState: string; authState: string;
authNonce: string; authNonce: string;
authVerifier: string; authVerifier: string;
user: {
name: string;
email?: string;
};
} }
type SessionFlashData = { type SessionFlashData = {
@ -23,6 +27,7 @@ export const {
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24, // 24 hours
path: '/', path: '/',
sameSite: 'lax', sameSite: 'lax',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secrets: [process.env.COOKIE_SECRET!], secrets: [process.env.COOKIE_SECRET!],
secure: true secure: true
} }