ui: Update user management pages
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
Developer-founders and indie hackers building SaaS products. They use Plunk to handle transactional and marketing email without the complexity of tools like Mailchimp or Customer.io. They notice tiny details — inconsistent spacing, placeholder text that adds no value, a button that doesn't communicate state. Context: professional environment, desktop-first.
|
||||
|
||||
### Brand Personality
|
||||
Sharp, minimal, confident. The product earns trust by being simple and correct, not by being flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design" — the brand is *care without noise*.
|
||||
|
||||
### Aesthetic Direction
|
||||
Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors. No color for decoration — only for semantics (red = error, green = success). Backgrounds are near-white with subtle texture. Cards use white with a neutral border and light shadow. Typography should feel precise and legible, not editorial. Spacing should feel considered, not generous.
|
||||
|
||||
### Design Principles
|
||||
1. **Every pixel earns its place.** If something doesn't communicate information or provide affordance, remove it.
|
||||
2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning states, not decoration.
|
||||
3. **Interaction should feel fast.** Loading states communicate exactly what's happening. No silent actions.
|
||||
4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they add value. Labels are unambiguous.
|
||||
5. **Consistency is trust.** The same pattern everywhere. One way to show errors. One way to show success. No creative variation in functional UI.
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useState} from 'react';
|
||||
@@ -27,11 +28,22 @@ import {useForm} from 'react-hook-form';
|
||||
import type {z} from 'zod';
|
||||
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {useUser} from '../../lib/hooks/useUser';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
const Spinner = () => (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Login() {
|
||||
const {mutate: userMutate} = useUser();
|
||||
const {mutate: projectsMutate} = useProjects();
|
||||
@@ -67,7 +79,7 @@ export default function Login() {
|
||||
>('POST', '/auth/login', values);
|
||||
|
||||
if (!response.success) {
|
||||
setErrorMessage('Email or password is not correct');
|
||||
setErrorMessage('Email or password is incorrect');
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
|
||||
@@ -108,9 +120,23 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Login" />
|
||||
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
|
||||
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
|
||||
<NextSeo title="Log in" />
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center py-12"
|
||||
style={{
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-md w-full px-4">
|
||||
<div className="flex items-center justify-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
|
||||
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Form {...form}>
|
||||
@@ -122,9 +148,9 @@ export default function Login() {
|
||||
className="p-8"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
|
||||
<p className="text-neutral-600">Enter your credentials to access your account</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
|
||||
<p className="text-sm text-neutral-500">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{(oauthConfig.github || oauthConfig.google) && (
|
||||
@@ -139,7 +165,7 @@ export default function Login() {
|
||||
window.location.href = `${API_URI}/oauth/google/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
@@ -169,7 +195,7 @@ export default function Login() {
|
||||
window.location.href = `${API_URI}/oauth/github/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
@@ -178,16 +204,16 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
<span className="w-full border-t border-neutral-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
|
||||
<span className="bg-white px-2 text-neutral-400 tracking-wider">or</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
@@ -195,87 +221,68 @@ export default function Login() {
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="hello@example.com" {...field} />
|
||||
<Input placeholder="you@example.com" autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
onClick={() => setShowReset(true)}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder="password" type={'password'} {...field} />
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline mt-1 text-left text-neutral-500"
|
||||
onClick={() => setShowReset(true)}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
initial={{opacity: 0, y: -8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500"
|
||||
exit={{opacity: 0, y: -8}}
|
||||
transition={{duration: 0.15}}
|
||||
className="text-sm text-red-500"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div layout>
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
'Login'
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Log in'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-neutral-500">
|
||||
<p className="text-center text-sm text-neutral-500">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900">
|
||||
<Link href="/auth/signup" className="text-neutral-900 underline underline-offset-4 hover:text-neutral-600 transition-colors">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -307,22 +314,30 @@ export default function Login() {
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
placeholder="you@example.com"
|
||||
value={resetEmail}
|
||||
onChange={e => setResetEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<div className={'w-full space-y-2'}>
|
||||
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
|
||||
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
|
||||
<div className="w-full space-y-2">
|
||||
<Button className="w-full" type="submit" disabled={resetStatus === 'loading'}>
|
||||
{resetStatus === 'loading' ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Send reset link'
|
||||
)}
|
||||
</Button>
|
||||
{resetStatus === 'success' && (
|
||||
<p className="text-green-600 text-sm">
|
||||
<p className="text-sm text-neutral-600">
|
||||
If an account exists, a reset link has been sent to your email.
|
||||
</p>
|
||||
)}
|
||||
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
|
||||
{resetStatus === 'error' && <p className="text-sm text-red-500">{resetError}</p>}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
@@ -22,6 +23,32 @@ import type {z} from 'zod';
|
||||
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
const dotGrid = {
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
|
||||
backgroundSize: '20px 20px',
|
||||
};
|
||||
|
||||
const Spinner = () => (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Wordmark = () => (
|
||||
<div className="flex items-center justify-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
|
||||
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ResetPassword() {
|
||||
const router = useRouter();
|
||||
const {token} = router.query;
|
||||
@@ -37,7 +64,6 @@ export default function ResetPassword() {
|
||||
},
|
||||
});
|
||||
|
||||
// Update form token when router is ready
|
||||
useEffect(() => {
|
||||
if (token && typeof token === 'string') {
|
||||
form.setValue('token', token);
|
||||
@@ -71,22 +97,25 @@ export default function ResetPassword() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Reset Password" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
|
||||
<div className="min-h-screen flex items-center justify-center py-12" style={dotGrid}>
|
||||
<div className="flex flex-col gap-6 max-w-md w-full px-4">
|
||||
<Wordmark />
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-red-600">Invalid reset link</h1>
|
||||
<p className="text-neutral-600">
|
||||
This password reset link is invalid. Please request a new one from the login page.
|
||||
</p>
|
||||
<Link href="/auth/login">
|
||||
<Button className="w-full mt-4">Back to login</Button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-xl font-bold tracking-tight">Invalid reset link</h1>
|
||||
<p className="text-sm text-neutral-500">
|
||||
This link is invalid or has expired. Request a new one from the login page.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/auth/login" className="mt-2">
|
||||
<Button>Back to login</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -100,29 +129,31 @@ export default function ResetPassword() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Reset Password" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
|
||||
<div className="min-h-screen flex items-center justify-center py-12" style={dotGrid}>
|
||||
<div className="flex flex-col gap-6 max-w-md w-full px-4">
|
||||
<Wordmark />
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<AnimatePresence mode="wait">
|
||||
{status === 'success' ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{opacity: 0, scale: 0.95}}
|
||||
initial={{opacity: 0, scale: 0.97}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="p-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-neutral-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-green-600">Password reset!</h1>
|
||||
<p className="text-neutral-600">
|
||||
Your password has been successfully reset. Redirecting to login...
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-xl font-bold tracking-tight">Password updated</h1>
|
||||
<p className="text-sm text-neutral-500">Redirecting you to login...</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
@@ -135,34 +166,33 @@ export default function ResetPassword() {
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Reset your password</h1>
|
||||
<p className="text-neutral-600">Enter your new password below</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Reset your password</h1>
|
||||
<p className="text-sm text-neutral-500">Enter your new password below</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter new password" type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>New password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="At least 6 characters" type="password" autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{status === 'error' && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
initial={{opacity: 0, y: -8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500"
|
||||
exit={{opacity: 0, y: -8}}
|
||||
transition={{duration: 0.15}}
|
||||
className="text-sm text-red-500"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
@@ -172,38 +202,23 @@ export default function ResetPassword() {
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<Spinner />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
'Reset password'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-neutral-500">
|
||||
<p className="text-center text-sm text-neutral-500">
|
||||
Remember your password?{' '}
|
||||
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-neutral-900 underline underline-offset-4 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useState} from 'react';
|
||||
@@ -21,11 +22,22 @@ import {useForm} from 'react-hook-form';
|
||||
import type {z} from 'zod';
|
||||
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {useUser} from '../../lib/hooks/useUser';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
const Spinner = () => (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Signup() {
|
||||
const {mutate: userMutate} = useUser();
|
||||
const {mutate: projectsMutate} = useProjects();
|
||||
@@ -57,7 +69,6 @@ export default function Signup() {
|
||||
>('POST', '/auth/signup', values);
|
||||
|
||||
if (!response.success) {
|
||||
// Handle error message from API
|
||||
const errorData = typeof response.data === 'string' ? response.data : 'Something went wrong';
|
||||
setErrorMessage(errorData);
|
||||
} else {
|
||||
@@ -76,8 +87,22 @@ export default function Signup() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Sign Up" />
|
||||
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
|
||||
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center py-12"
|
||||
style={{
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-md w-full px-4">
|
||||
<div className="flex items-center justify-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
|
||||
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Form {...form}>
|
||||
@@ -89,9 +114,9 @@ export default function Signup() {
|
||||
className="p-8"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
|
||||
<p className="text-neutral-600">Get started with Plunk today</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Create an account</h1>
|
||||
<p className="text-sm text-neutral-500">Start sending emails in minutes</p>
|
||||
</div>
|
||||
|
||||
{(oauthConfig.github || oauthConfig.google) && (
|
||||
@@ -106,7 +131,7 @@ export default function Signup() {
|
||||
window.location.href = `${API_URI}/oauth/google/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
@@ -136,7 +161,7 @@ export default function Signup() {
|
||||
window.location.href = `${API_URI}/oauth/github/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
@@ -145,16 +170,16 @@ export default function Signup() {
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
<span className="w-full border-t border-neutral-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
|
||||
<span className="bg-white px-2 text-neutral-400 tracking-wider">or</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
@@ -162,14 +187,13 @@ export default function Signup() {
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="hello@example.com" {...field} />
|
||||
<Input placeholder="you@example.com" autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
@@ -177,7 +201,7 @@ export default function Signup() {
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
|
||||
<Input placeholder="At least 6 characters" type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -188,53 +212,34 @@ export default function Signup() {
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
initial={{opacity: 0, y: -8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500"
|
||||
exit={{opacity: 0, y: -8}}
|
||||
transition={{duration: 0.15}}
|
||||
className="text-sm text-red-500"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div layout>
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
'Sign up'
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-neutral-500">
|
||||
<p className="text-center text-sm text-neutral-500">
|
||||
Already have an account?{' '}
|
||||
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
|
||||
Login
|
||||
<Link href="/auth/login" className="text-neutral-900 underline underline-offset-4 hover:text-neutral-600 transition-colors">
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -2,12 +2,30 @@ import {AuthenticationSchemas} from '@plunk/shared';
|
||||
import {Button, Card, CardContent} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
const dotGrid = {
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
|
||||
backgroundSize: '20px 20px',
|
||||
};
|
||||
|
||||
const Spinner = () => (
|
||||
<svg className="h-6 w-6 animate-spin text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const router = useRouter();
|
||||
const {token} = router.query;
|
||||
@@ -21,7 +39,6 @@ export default function VerifyEmail() {
|
||||
const processedToken = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for router to be ready before processing
|
||||
if (!router.isReady) {
|
||||
return;
|
||||
}
|
||||
@@ -34,7 +51,6 @@ export default function VerifyEmail() {
|
||||
|
||||
processedToken.current = normalizedToken;
|
||||
|
||||
// If no token, show the pending verification state
|
||||
if (!token || typeof token !== 'string') {
|
||||
setStatus('pending');
|
||||
return;
|
||||
@@ -69,29 +85,24 @@ export default function VerifyEmail() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady, token]);
|
||||
|
||||
// Initialize cooldown from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedExpiry = localStorage.getItem('plunk:email-verification-cooldown');
|
||||
if (storedExpiry) {
|
||||
const expiryTime = parseInt(storedExpiry, 10);
|
||||
// Validate: not NaN, in the future, and within reasonable range (< 1 hour from now)
|
||||
if (!isNaN(expiryTime) && expiryTime > Date.now() && expiryTime < Date.now() + 3600000) {
|
||||
setCooldownExpiry(expiryTime);
|
||||
} else {
|
||||
// Clean up invalid/expired cooldown
|
||||
localStorage.removeItem('plunk:email-verification-cooldown');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Countdown timer effect
|
||||
useEffect(() => {
|
||||
if (!cooldownExpiry) {
|
||||
setRemainingSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update immediately
|
||||
const updateRemaining = () => {
|
||||
const remaining = Math.max(0, Math.ceil((cooldownExpiry - Date.now()) / 1000));
|
||||
setRemainingSeconds(remaining);
|
||||
@@ -116,7 +127,6 @@ export default function VerifyEmail() {
|
||||
|
||||
if (response.success) {
|
||||
setResendMessage('Verification email sent! Please check your inbox.');
|
||||
// Set 60-second cooldown
|
||||
const expiryTime = Date.now() + 60000;
|
||||
setCooldownExpiry(expiryTime);
|
||||
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
|
||||
@@ -124,9 +134,7 @@ export default function VerifyEmail() {
|
||||
setResendMessage('Failed to send verification email. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error message but still apply cooldown to prevent spam
|
||||
setResendMessage(error instanceof Error ? error.message : 'Failed to send verification email. Please try again.');
|
||||
// Apply cooldown even on error to prevent retry spam
|
||||
const expiryTime = Date.now() + 60000;
|
||||
setCooldownExpiry(expiryTime);
|
||||
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
|
||||
@@ -138,8 +146,15 @@ export default function VerifyEmail() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Verify Email" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
|
||||
<div className="min-h-screen flex items-center justify-center py-12" style={dotGrid}>
|
||||
<div className="flex flex-col gap-6 max-w-md w-full px-4">
|
||||
<div className="flex items-center justify-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
|
||||
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col gap-6 text-center">
|
||||
@@ -147,13 +162,14 @@ export default function VerifyEmail() {
|
||||
{status === 'pending' && (
|
||||
<motion.div
|
||||
key="pending"
|
||||
initial={{opacity: 0, scale: 0.95}}
|
||||
initial={{opacity: 0, scale: 0.97}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="h-16 w-16 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -162,21 +178,24 @@ export default function VerifyEmail() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Verify your email</h1>
|
||||
<p className="text-neutral-600">
|
||||
Please check your inbox for a verification link. Click the link in the email to verify your
|
||||
account.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-xl font-bold tracking-tight">Check your email</h1>
|
||||
<p className="text-sm text-neutral-500">
|
||||
We sent a verification link to your inbox. Click it to verify your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full mt-4">
|
||||
<div className="flex flex-col gap-2 w-full mt-2">
|
||||
<Button onClick={handleResend} disabled={isResending || cooldownExpiry !== null} className="w-full">
|
||||
{isResending ? 'Sending...' : cooldownExpiry !== null ? `Resend in ${remainingSeconds}s` : 'Resend verification email'}
|
||||
{isResending
|
||||
? 'Sending...'
|
||||
: cooldownExpiry !== null
|
||||
? `Resend in ${remainingSeconds}s`
|
||||
: 'Resend verification email'}
|
||||
</Button>
|
||||
|
||||
{resendMessage && (
|
||||
<p
|
||||
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
|
||||
>
|
||||
<p className={`text-sm ${resendMessage.includes('sent') ? 'text-neutral-600' : 'text-red-500'}`}>
|
||||
{resendMessage}
|
||||
</p>
|
||||
)}
|
||||
@@ -196,73 +215,70 @@ export default function VerifyEmail() {
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="h-16 w-16 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin text-neutral-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-xl font-bold tracking-tight">Verifying...</h1>
|
||||
<p className="text-sm text-neutral-500">Please wait a moment.</p>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Verifying your email...</h1>
|
||||
<p className="text-neutral-600">Please wait while we verify your email address.</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{opacity: 0, scale: 0.95}}
|
||||
initial={{opacity: 0, scale: 0.97}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-neutral-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-green-600">Email verified!</h1>
|
||||
<p className="text-neutral-600">
|
||||
Your email has been successfully verified. Redirecting to dashboard...
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-xl font-bold tracking-tight">Email verified</h1>
|
||||
<p className="text-sm text-neutral-500">Redirecting to your dashboard...</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<motion.div
|
||||
key="error"
|
||||
initial={{opacity: 0, scale: 0.95}}
|
||||
initial={{opacity: 0, scale: 0.97}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="h-12 w-12 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-red-600">Verification failed</h1>
|
||||
<p className="text-neutral-600">{errorMessage}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-xl font-bold tracking-tight">Verification failed</h1>
|
||||
<p className="text-sm text-neutral-500">{errorMessage}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full mt-4">
|
||||
<div className="flex flex-col gap-2 w-full mt-2">
|
||||
<Button onClick={handleResend} disabled={isResending || cooldownExpiry !== null} className="w-full">
|
||||
{isResending ? 'Sending...' : cooldownExpiry !== null ? `Resend in ${remainingSeconds}s` : 'Resend verification email'}
|
||||
{isResending
|
||||
? 'Sending...'
|
||||
: cooldownExpiry !== null
|
||||
? `Resend in ${remainingSeconds}s`
|
||||
: 'Resend verification email'}
|
||||
</Button>
|
||||
|
||||
{resendMessage && (
|
||||
<p
|
||||
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
|
||||
>
|
||||
<p className={`text-sm ${resendMessage.includes('sent') ? 'text-neutral-600' : 'text-red-500'}`}>
|
||||
{resendMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {ProjectSchemas, SUPPORTED_LANGUAGES} from '@plunk/shared';
|
||||
import {TrackingMode} from '@plunk/db';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -825,14 +826,14 @@ export default function Settings() {
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
Regenerate API Keys
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<DialogDescription className="space-y-3">
|
||||
<p>Are you sure you want to regenerate your API keys?</p>
|
||||
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs">
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any
|
||||
applications using the old keys will stop working until you update them with the new keys.
|
||||
</div>
|
||||
<AlertDescription>
|
||||
Current keys will be <strong>immediately invalidated</strong>. Any integrations using the old keys
|
||||
will stop working until updated.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
Reference in New Issue
Block a user