feat(profile): /settings/account — name, email, password

Replaces template's mock account form with real Appwrite-backed actions.

Server actions (lib/appwrite/profile-actions.ts):
- updateNameAction: account.updateName via session SDK; revalidates layout
  so the new name shows in sidebar/header right away.
- updateEmailAction: account.updateEmail (requires current password as
  Appwrite confirmation). Maps user_email_already_exists to a friendly
  Turkish message.
- updatePasswordAction: account.updatePassword(new, old). Validates
  old != empty, new >= 8 chars, new === confirm. Maps
  user_password_recently_used / user_password_mismatch.
- All audit-logged with entityType user_name / user_email / user_password
  and tenantId of the user's currently-active workspace (or 'global' if
  none). Audit failures swallowed.

UI:
- /settings/account is now an async server page that pulls the user via
  getCurrentUser, renders an account info card ($id, registration date,
  email verification, MFA), then 3 separate small forms — one card each
  — for name, email, password. Each form clears its own state and gives
  toast feedback independently.
- Removed the template's react-hook-form-based mock page.

Side note: skipped email-verification flow + MFA setup for later.
This commit is contained in:
kovakmedya
2026-04-30 06:47:53 +03:00
parent fc091b9e0d
commit 89d456fc76
6 changed files with 441 additions and 199 deletions
@@ -0,0 +1,83 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateEmailAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function EmailForm({ currentEmail }: { currentEmail: string }) {
const [state, formAction, isPending] = useActionState(updateEmailAction, initialProfileState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Email güncellendi.");
// Clear password field after success
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Email adresi</CardTitle>
<CardDescription>
Email değiştirmek için mevcut şifrenizi de girin. Yeni email ile giriş yapmaya devam edersiniz.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">Yeni email</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={currentEmail}
required
/>
{state.fieldErrors?.email && (
<p className="text-destructive text-xs">{state.fieldErrors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="email-password">Şifre (doğrulama)</Label>
<Input
id="email-password"
name="password"
type="password"
autoComplete="current-password"
required
/>
{state.fieldErrors?.password && (
<p className="text-destructive text-xs">{state.fieldErrors.password}</p>
)}
</div>
<div className="md:col-span-2 flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Güncelleniyor...
</>
) : (
<>
<Save className="size-4" />
Email'i güncelle
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateNameAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function NameForm({ currentName }: { currentName: string }) {
const [state, formAction, isPending] = useActionState(updateNameAction, initialProfileState);
useEffect(() => {
if (state.ok) toast.success("İsim güncellendi.");
else if (state.error) toast.error(state.error);
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Görünür isim</CardTitle>
<CardDescription>
Header'da, davetlerde ve takım listesinde görünecek isim.
</CardDescription>
</CardHeader>
<CardContent>
<form action={formAction} className="grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
<div className="grid gap-2">
<Label htmlFor="name">İsim</Label>
<Input id="name" name="name" defaultValue={currentName} required maxLength={128} />
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updatePasswordAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function PasswordForm() {
const [state, formAction, isPending] = useActionState(
updatePasswordAction,
initialProfileState,
);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Şifre değiştirildi.");
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Şifre</CardTitle>
<CardDescription>
Şifrenizi değiştirmek için mevcut şifrenizi ve yeni şifreyi iki kez girin.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="oldPassword">Mevcut şifre</Label>
<Input
id="oldPassword"
name="oldPassword"
type="password"
autoComplete="current-password"
required
/>
{state.fieldErrors?.oldPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.oldPassword}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="newPassword">Yeni şifre</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
{state.fieldErrors?.newPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.newPassword}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">Yeni şifre (tekrar)</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
{state.fieldErrors?.confirmPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.confirmPassword}</p>
)}
</div>
<div className="md:col-span-3 flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Güncelleniyor...
</>
) : (
<>
<KeyRound className="size-4" />
Şifreyi değiştir
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
+54 -199
View File
@@ -1,207 +1,62 @@
"use client"
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { getCurrentUser } from "@/lib/appwrite/server";
import { formatDateTime } from "@/lib/format";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
const accountFormSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
username: z.string().min(3, "Username must be at least 3 characters"),
currentPassword: z.string().optional(),
newPassword: z.string().optional(),
confirmPassword: z.string().optional(),
})
import { NameForm } from "./components/name-form";
import { EmailForm } from "./components/email-form";
import { PasswordForm } from "./components/password-form";
type AccountFormValues = z.infer<typeof accountFormSchema>
export const metadata: Metadata = {
title: "İşletmem — Profil",
};
export default function AccountSettings() {
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
username: "",
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
})
function onSubmit(data: AccountFormValues) {
console.log("Form submitted:", data)
// Here you would typically save the data
}
export default async function AccountSettingsPage() {
const user = await getCurrentUser();
if (!user) redirect("/sign-in");
return (
<div className="space-y-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Account Settings</h1>
<p className="text-muted-foreground">
Manage your account settings and preferences.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Personal Information</CardTitle>
<CardDescription>
Update your personal information that will be displayed on your profile.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter your first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter your last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter your username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter current password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter new password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Confirm new password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Danger Zone</CardTitle>
<CardDescription>
Irreversible and destructive actions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Separator />
<div className="flex flex-wrap gap-2 items-center justify-between">
<div>
<h4 className="font-semibold">Delete Account</h4>
<p className="text-sm text-muted-foreground">
Permanently delete your account and all associated data.
</p>
</div>
<Button variant="destructive" type="button" className="cursor-pointer">
Delete Account
</Button>
</div>
</CardContent>
</Card>
<div className="flex space-x-2">
<Button type="submit" className="cursor-pointer">Save Changes</Button>
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
</div>
</form>
</Form>
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">Profil ayarları</p>
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.email}</h1>
<p className="text-muted-foreground text-sm">
Hesap bilgilerinizi ve şifrenizi buradan yönetin.
</p>
</div>
)
<Card>
<CardHeader>
<CardTitle>Hesap bilgileri</CardTitle>
<CardDescription>Kayıt tarihi ve hesap durumu</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid gap-4 text-sm md:grid-cols-2">
<div>
<dt className="text-muted-foreground text-xs uppercase">Hesap ID</dt>
<dd className="mt-1 font-mono text-xs">{user.$id}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">Kayıt tarihi</dt>
<dd className="mt-1">{formatDateTime(user.registration)}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">Email doğrulanmış</dt>
<dd className="mt-1">{user.emailVerification ? "Evet" : "Hayır"}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">İki faktör (2FA)</dt>
<dd className="mt-1">{user.mfa ? "Açık" : "Kapalı"}</dd>
</div>
</dl>
</CardContent>
</Card>
<NameForm currentName={user.name || ""} />
<EmailForm currentEmail={user.email} />
<PasswordForm />
</div>
);
}