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';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import React, {useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
@@ -27,11 +28,22 @@ import {useForm} from 'react-hook-form';
|
|||||||
import type {z} from 'zod';
|
import type {z} from 'zod';
|
||||||
|
|
||||||
import {API_URI} from '../../lib/constants';
|
import {API_URI} from '../../lib/constants';
|
||||||
|
import {useConfig} from '../../lib/hooks/useConfig';
|
||||||
import {useProjects} from '../../lib/hooks/useProject';
|
import {useProjects} from '../../lib/hooks/useProject';
|
||||||
import {useUser} from '../../lib/hooks/useUser';
|
import {useUser} from '../../lib/hooks/useUser';
|
||||||
import {useConfig} from '../../lib/hooks/useConfig';
|
|
||||||
import {network} from '../../lib/network';
|
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() {
|
export default function Login() {
|
||||||
const {mutate: userMutate} = useUser();
|
const {mutate: userMutate} = useUser();
|
||||||
const {mutate: projectsMutate} = useProjects();
|
const {mutate: projectsMutate} = useProjects();
|
||||||
@@ -67,7 +79,7 @@ export default function Login() {
|
|||||||
>('POST', '/auth/login', values);
|
>('POST', '/auth/login', values);
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
setErrorMessage('Email or password is not correct');
|
setErrorMessage('Email or password is incorrect');
|
||||||
} else {
|
} else {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
@@ -108,9 +120,23 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Login" />
|
<NextSeo title="Log in" />
|
||||||
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
|
<div
|
||||||
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
|
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>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -122,9 +148,9 @@ export default function Login() {
|
|||||||
className="p-8"
|
className="p-8"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
|
||||||
<p className="text-neutral-600">Enter your credentials to access your account</p>
|
<p className="text-sm text-neutral-500">Sign in to your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(oauthConfig.github || oauthConfig.google) && (
|
{(oauthConfig.github || oauthConfig.google) && (
|
||||||
@@ -139,7 +165,7 @@ export default function Login() {
|
|||||||
window.location.href = `${API_URI}/oauth/google/outbound`;
|
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
|
<path
|
||||||
fill="currentColor"
|
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"
|
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`;
|
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" />
|
<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>
|
</svg>
|
||||||
Continue with GitHub
|
Continue with GitHub
|
||||||
@@ -178,16 +204,16 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<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>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
@@ -195,87 +221,68 @@ export default function Login() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="hello@example.com" {...field} />
|
<Input placeholder="you@example.com" autoFocus {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<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>
|
<FormControl>
|
||||||
<Input placeholder="password" type={'password'} {...field} />
|
<Input type="password" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs underline mt-1 text-left text-neutral-500"
|
|
||||||
onClick={() => setShowReset(true)}
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{opacity: 0, y: -10}}
|
initial={{opacity: 0, y: -8}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
exit={{opacity: 0, y: -10}}
|
exit={{opacity: 0, y: -8}}
|
||||||
className="text-sm font-medium text-red-500"
|
transition={{duration: 0.15}}
|
||||||
|
className="text-sm text-red-500"
|
||||||
>
|
>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<motion.div layout>
|
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
{form.formState.isSubmitting ? (
|
||||||
{form.formState.isSubmitting ? (
|
<>
|
||||||
<>
|
<Spinner />
|
||||||
<svg
|
Signing in...
|
||||||
className="h-4 w-4 animate-spin"
|
</>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
) : (
|
||||||
fill="none"
|
'Log in'
|
||||||
viewBox="0 0 24 24"
|
)}
|
||||||
>
|
</Button>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="text-center text-sm text-neutral-500">
|
<p className="text-center text-sm text-neutral-500">
|
||||||
Don't have an account?{' '}
|
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
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -307,22 +314,30 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Enter your email"
|
placeholder="you@example.com"
|
||||||
value={resetEmail}
|
value={resetEmail}
|
||||||
onChange={e => setResetEmail(e.target.value)}
|
onChange={e => setResetEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className={'w-full space-y-2'}>
|
<div className="w-full space-y-2">
|
||||||
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
|
<Button className="w-full" type="submit" disabled={resetStatus === 'loading'}>
|
||||||
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
|
{resetStatus === 'loading' ? (
|
||||||
|
<>
|
||||||
|
<Spinner />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Send reset link'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{resetStatus === 'success' && (
|
{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.
|
If an account exists, a reset link has been sent to your email.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
|
{resetStatus === 'error' && <p className="text-sm text-red-500">{resetError}</p>}
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
@@ -22,6 +23,32 @@ import type {z} from 'zod';
|
|||||||
|
|
||||||
import {network} from '../../lib/network';
|
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() {
|
export default function ResetPassword() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {token} = router.query;
|
const {token} = router.query;
|
||||||
@@ -37,7 +64,6 @@ export default function ResetPassword() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update form token when router is ready
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token && typeof token === 'string') {
|
if (token && typeof token === 'string') {
|
||||||
form.setValue('token', token);
|
form.setValue('token', token);
|
||||||
@@ -71,22 +97,25 @@ export default function ResetPassword() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Reset Password" />
|
<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">
|
<div className="flex flex-col gap-6 max-w-md w-full px-4">
|
||||||
|
<Wordmark />
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-8">
|
<CardContent className="p-8">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<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">
|
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||||
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-red-600">Invalid reset link</h1>
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-neutral-600">
|
<h1 className="text-xl font-bold tracking-tight">Invalid reset link</h1>
|
||||||
This password reset link is invalid. Please request a new one from the login page.
|
<p className="text-sm text-neutral-500">
|
||||||
</p>
|
This link is invalid or has expired. Request a new one from the login page.
|
||||||
<Link href="/auth/login">
|
</p>
|
||||||
<Button className="w-full mt-4">Back to login</Button>
|
</div>
|
||||||
|
<Link href="/auth/login" className="mt-2">
|
||||||
|
<Button>Back to login</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -100,29 +129,31 @@ export default function ResetPassword() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Reset Password" />
|
<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">
|
<div className="flex flex-col gap-6 max-w-md w-full px-4">
|
||||||
|
<Wordmark />
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{status === 'success' ? (
|
{status === 'success' ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="success"
|
key="success"
|
||||||
initial={{opacity: 0, scale: 0.95}}
|
initial={{opacity: 0, scale: 0.97}}
|
||||||
animate={{opacity: 1, scale: 1}}
|
animate={{opacity: 1, scale: 1}}
|
||||||
exit={{opacity: 0}}
|
exit={{opacity: 0}}
|
||||||
|
transition={{duration: 0.2}}
|
||||||
className="p-8"
|
className="p-8"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<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">
|
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||||
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-green-600">Password reset!</h1>
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-neutral-600">
|
<h1 className="text-xl font-bold tracking-tight">Password updated</h1>
|
||||||
Your password has been successfully reset. Redirecting to login...
|
<p className="text-sm text-neutral-500">Redirecting you to login...</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
@@ -135,34 +166,33 @@ export default function ResetPassword() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Reset your password</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Reset your password</h1>
|
||||||
<p className="text-neutral-600">Enter your new password below</p>
|
<p className="text-sm text-neutral-500">Enter your new password below</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="newPassword"
|
||||||
name="newPassword"
|
render={({field}) => (
|
||||||
render={({field}) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>New password</FormLabel>
|
||||||
<FormLabel>New Password</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input placeholder="At least 6 characters" type="password" autoFocus {...field} />
|
||||||
<Input placeholder="Enter new password" type="password" {...field} />
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{opacity: 0, y: -10}}
|
initial={{opacity: 0, y: -8}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
exit={{opacity: 0, y: -10}}
|
exit={{opacity: 0, y: -8}}
|
||||||
className="text-sm font-medium text-red-500"
|
transition={{duration: 0.15}}
|
||||||
|
className="text-sm text-red-500"
|
||||||
>
|
>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
@@ -172,38 +202,23 @@ export default function ResetPassword() {
|
|||||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||||
{form.formState.isSubmitting ? (
|
{form.formState.isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<svg
|
<Spinner />
|
||||||
className="h-4 w-4 animate-spin"
|
Resetting...
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Reset password'
|
'Reset password'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-center text-sm text-neutral-500">
|
<p className="text-center text-sm text-neutral-500">
|
||||||
Remember your password?{' '}
|
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
|
Back to login
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import React, {useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
@@ -21,11 +22,22 @@ import {useForm} from 'react-hook-form';
|
|||||||
import type {z} from 'zod';
|
import type {z} from 'zod';
|
||||||
|
|
||||||
import {API_URI} from '../../lib/constants';
|
import {API_URI} from '../../lib/constants';
|
||||||
|
import {useConfig} from '../../lib/hooks/useConfig';
|
||||||
import {useProjects} from '../../lib/hooks/useProject';
|
import {useProjects} from '../../lib/hooks/useProject';
|
||||||
import {useUser} from '../../lib/hooks/useUser';
|
import {useUser} from '../../lib/hooks/useUser';
|
||||||
import {useConfig} from '../../lib/hooks/useConfig';
|
|
||||||
import {network} from '../../lib/network';
|
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() {
|
export default function Signup() {
|
||||||
const {mutate: userMutate} = useUser();
|
const {mutate: userMutate} = useUser();
|
||||||
const {mutate: projectsMutate} = useProjects();
|
const {mutate: projectsMutate} = useProjects();
|
||||||
@@ -57,7 +69,6 @@ export default function Signup() {
|
|||||||
>('POST', '/auth/signup', values);
|
>('POST', '/auth/signup', values);
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
// Handle error message from API
|
|
||||||
const errorData = typeof response.data === 'string' ? response.data : 'Something went wrong';
|
const errorData = typeof response.data === 'string' ? response.data : 'Something went wrong';
|
||||||
setErrorMessage(errorData);
|
setErrorMessage(errorData);
|
||||||
} else {
|
} else {
|
||||||
@@ -76,8 +87,22 @@ export default function Signup() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Sign Up" />
|
<NextSeo title="Sign Up" />
|
||||||
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
|
<div
|
||||||
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
|
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>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -89,9 +114,9 @@ export default function Signup() {
|
|||||||
className="p-8"
|
className="p-8"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Create an account</h1>
|
||||||
<p className="text-neutral-600">Get started with Plunk today</p>
|
<p className="text-sm text-neutral-500">Start sending emails in minutes</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(oauthConfig.github || oauthConfig.google) && (
|
{(oauthConfig.github || oauthConfig.google) && (
|
||||||
@@ -106,7 +131,7 @@ export default function Signup() {
|
|||||||
window.location.href = `${API_URI}/oauth/google/outbound`;
|
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
|
<path
|
||||||
fill="currentColor"
|
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"
|
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`;
|
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" />
|
<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>
|
</svg>
|
||||||
Continue with GitHub
|
Continue with GitHub
|
||||||
@@ -145,16 +170,16 @@ export default function Signup() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<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>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
@@ -162,14 +187,13 @@ export default function Signup() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="hello@example.com" {...field} />
|
<Input placeholder="you@example.com" autoFocus {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
@@ -177,7 +201,7 @@ export default function Signup() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
|
<Input placeholder="At least 6 characters" type="password" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -188,53 +212,34 @@ export default function Signup() {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{opacity: 0, y: -10}}
|
initial={{opacity: 0, y: -8}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
exit={{opacity: 0, y: -10}}
|
exit={{opacity: 0, y: -8}}
|
||||||
className="text-sm font-medium text-red-500"
|
transition={{duration: 0.15}}
|
||||||
|
className="text-sm text-red-500"
|
||||||
>
|
>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<motion.div layout>
|
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
{form.formState.isSubmitting ? (
|
||||||
{form.formState.isSubmitting ? (
|
<>
|
||||||
<>
|
<Spinner />
|
||||||
<svg
|
Creating account...
|
||||||
className="h-4 w-4 animate-spin"
|
</>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
) : (
|
||||||
fill="none"
|
'Create account'
|
||||||
viewBox="0 0 24 24"
|
)}
|
||||||
>
|
</Button>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="text-center text-sm text-neutral-500">
|
<p className="text-center text-sm text-neutral-500">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<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">
|
||||||
Login
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -2,12 +2,30 @@ import {AuthenticationSchemas} from '@plunk/shared';
|
|||||||
import {Button, Card, CardContent} from '@plunk/ui';
|
import {Button, Card, CardContent} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import React, {useEffect, useRef, useState} from 'react';
|
import React, {useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
import {network} from '../../lib/network';
|
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() {
|
export default function VerifyEmail() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {token} = router.query;
|
const {token} = router.query;
|
||||||
@@ -21,7 +39,6 @@ export default function VerifyEmail() {
|
|||||||
const processedToken = useRef<string | undefined>(undefined);
|
const processedToken = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for router to be ready before processing
|
|
||||||
if (!router.isReady) {
|
if (!router.isReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -34,7 +51,6 @@ export default function VerifyEmail() {
|
|||||||
|
|
||||||
processedToken.current = normalizedToken;
|
processedToken.current = normalizedToken;
|
||||||
|
|
||||||
// If no token, show the pending verification state
|
|
||||||
if (!token || typeof token !== 'string') {
|
if (!token || typeof token !== 'string') {
|
||||||
setStatus('pending');
|
setStatus('pending');
|
||||||
return;
|
return;
|
||||||
@@ -69,29 +85,24 @@ export default function VerifyEmail() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [router.isReady, token]);
|
}, [router.isReady, token]);
|
||||||
|
|
||||||
// Initialize cooldown from localStorage on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedExpiry = localStorage.getItem('plunk:email-verification-cooldown');
|
const storedExpiry = localStorage.getItem('plunk:email-verification-cooldown');
|
||||||
if (storedExpiry) {
|
if (storedExpiry) {
|
||||||
const expiryTime = parseInt(storedExpiry, 10);
|
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) {
|
if (!isNaN(expiryTime) && expiryTime > Date.now() && expiryTime < Date.now() + 3600000) {
|
||||||
setCooldownExpiry(expiryTime);
|
setCooldownExpiry(expiryTime);
|
||||||
} else {
|
} else {
|
||||||
// Clean up invalid/expired cooldown
|
|
||||||
localStorage.removeItem('plunk:email-verification-cooldown');
|
localStorage.removeItem('plunk:email-verification-cooldown');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Countdown timer effect
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cooldownExpiry) {
|
if (!cooldownExpiry) {
|
||||||
setRemainingSeconds(0);
|
setRemainingSeconds(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update immediately
|
|
||||||
const updateRemaining = () => {
|
const updateRemaining = () => {
|
||||||
const remaining = Math.max(0, Math.ceil((cooldownExpiry - Date.now()) / 1000));
|
const remaining = Math.max(0, Math.ceil((cooldownExpiry - Date.now()) / 1000));
|
||||||
setRemainingSeconds(remaining);
|
setRemainingSeconds(remaining);
|
||||||
@@ -116,7 +127,6 @@ export default function VerifyEmail() {
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setResendMessage('Verification email sent! Please check your inbox.');
|
setResendMessage('Verification email sent! Please check your inbox.');
|
||||||
// Set 60-second cooldown
|
|
||||||
const expiryTime = Date.now() + 60000;
|
const expiryTime = Date.now() + 60000;
|
||||||
setCooldownExpiry(expiryTime);
|
setCooldownExpiry(expiryTime);
|
||||||
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
|
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.');
|
setResendMessage('Failed to send verification email. Please try again.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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.');
|
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;
|
const expiryTime = Date.now() + 60000;
|
||||||
setCooldownExpiry(expiryTime);
|
setCooldownExpiry(expiryTime);
|
||||||
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
|
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
|
||||||
@@ -138,8 +146,15 @@ export default function VerifyEmail() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Verify Email" />
|
<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 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>
|
<Card>
|
||||||
<CardContent className="p-8">
|
<CardContent className="p-8">
|
||||||
<div className="flex flex-col gap-6 text-center">
|
<div className="flex flex-col gap-6 text-center">
|
||||||
@@ -147,13 +162,14 @@ export default function VerifyEmail() {
|
|||||||
{status === 'pending' && (
|
{status === 'pending' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="pending"
|
key="pending"
|
||||||
initial={{opacity: 0, scale: 0.95}}
|
initial={{opacity: 0, scale: 0.97}}
|
||||||
animate={{opacity: 1, scale: 1}}
|
animate={{opacity: 1, scale: 1}}
|
||||||
exit={{opacity: 0}}
|
exit={{opacity: 0}}
|
||||||
|
transition={{duration: 0.2}}
|
||||||
className="flex flex-col items-center gap-4"
|
className="flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="h-16 w-16 rounded-full bg-blue-100 flex items-center justify-center">
|
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||||
<svg className="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-6 w-6 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -162,21 +178,24 @@ export default function VerifyEmail() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Verify your email</h1>
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-neutral-600">
|
<h1 className="text-xl font-bold tracking-tight">Check your email</h1>
|
||||||
Please check your inbox for a verification link. Click the link in the email to verify your
|
<p className="text-sm text-neutral-500">
|
||||||
account.
|
We sent a verification link to your inbox. Click it to verify your account.
|
||||||
</p>
|
</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">
|
<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>
|
</Button>
|
||||||
|
|
||||||
{resendMessage && (
|
{resendMessage && (
|
||||||
<p
|
<p className={`text-sm ${resendMessage.includes('sent') ? 'text-neutral-600' : 'text-red-500'}`}>
|
||||||
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
|
|
||||||
>
|
|
||||||
{resendMessage}
|
{resendMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -196,73 +215,70 @@ export default function VerifyEmail() {
|
|||||||
initial={{opacity: 0}}
|
initial={{opacity: 0}}
|
||||||
animate={{opacity: 1}}
|
animate={{opacity: 1}}
|
||||||
exit={{opacity: 0}}
|
exit={{opacity: 0}}
|
||||||
|
transition={{duration: 0.2}}
|
||||||
className="flex flex-col items-center gap-4"
|
className="flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="h-16 w-16 rounded-full bg-neutral-100 flex items-center justify-center">
|
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||||
<svg
|
<Spinner />
|
||||||
className="h-8 w-8 animate-spin text-neutral-600"
|
</div>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div className="flex flex-col gap-1.5">
|
||||||
fill="none"
|
<h1 className="text-xl font-bold tracking-tight">Verifying...</h1>
|
||||||
viewBox="0 0 24 24"
|
<p className="text-sm text-neutral-500">Please wait a moment.</p>
|
||||||
>
|
|
||||||
<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>
|
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="success"
|
key="success"
|
||||||
initial={{opacity: 0, scale: 0.95}}
|
initial={{opacity: 0, scale: 0.97}}
|
||||||
animate={{opacity: 1, scale: 1}}
|
animate={{opacity: 1, scale: 1}}
|
||||||
exit={{opacity: 0}}
|
exit={{opacity: 0}}
|
||||||
|
transition={{duration: 0.2}}
|
||||||
className="flex flex-col items-center gap-4"
|
className="flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
|
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
|
||||||
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-green-600">Email verified!</h1>
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-neutral-600">
|
<h1 className="text-xl font-bold tracking-tight">Email verified</h1>
|
||||||
Your email has been successfully verified. Redirecting to dashboard...
|
<p className="text-sm text-neutral-500">Redirecting to your dashboard...</p>
|
||||||
</p>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="error"
|
key="error"
|
||||||
initial={{opacity: 0, scale: 0.95}}
|
initial={{opacity: 0, scale: 0.97}}
|
||||||
animate={{opacity: 1, scale: 1}}
|
animate={{opacity: 1, scale: 1}}
|
||||||
exit={{opacity: 0}}
|
exit={{opacity: 0}}
|
||||||
|
transition={{duration: 0.2}}
|
||||||
className="flex flex-col items-center gap-4"
|
className="flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
|
<div className="h-12 w-12 rounded-full bg-red-50 flex items-center justify-center">
|
||||||
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-red-600">Verification failed</h1>
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-neutral-600">{errorMessage}</p>
|
<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">
|
<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>
|
</Button>
|
||||||
|
|
||||||
{resendMessage && (
|
{resendMessage && (
|
||||||
<p
|
<p className={`text-sm ${resendMessage.includes('sent') ? 'text-neutral-600' : 'text-red-500'}`}>
|
||||||
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
|
|
||||||
>
|
|
||||||
{resendMessage}
|
{resendMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {ProjectSchemas, SUPPORTED_LANGUAGES} from '@plunk/shared';
|
|||||||
import {TrackingMode} from '@plunk/db';
|
import {TrackingMode} from '@plunk/db';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -825,14 +826,14 @@ export default function Settings() {
|
|||||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||||
Regenerate API Keys
|
Regenerate API Keys
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="space-y-2">
|
<DialogDescription className="space-y-3">
|
||||||
<p>Are you sure you want to regenerate your API keys?</p>
|
<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" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<div className="ml-2">
|
<AlertDescription>
|
||||||
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any
|
Current keys will be <strong>immediately invalidated</strong>. Any integrations using the old keys
|
||||||
applications using the old keys will stop working until you update them with the new keys.
|
will stop working until updated.
|
||||||
</div>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user