feat: add logout and dropdown
This commit is contained in:
parent
3f65c7e610
commit
50bcfa9c04
@ -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'/>}/>
|
||||||
|
|||||||
@ -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
15
app/routes/logout.tsx
Normal 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
7
app/types/Key.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export type Key = {
|
||||||
|
id: string;
|
||||||
|
prefix: string;
|
||||||
|
expiration: string;
|
||||||
|
createdAt: Date;
|
||||||
|
lastSeen: Date;
|
||||||
|
}
|
||||||
@ -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'
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user