612 lines
22 KiB
TypeScript
612 lines
22 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
|
||
interface User {
|
||
id: number;
|
||
username: string;
|
||
email: string;
|
||
role: string;
|
||
isPremium: boolean;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface ResetResult {
|
||
to: string;
|
||
subject: string;
|
||
body: string;
|
||
temporaryPassword: string;
|
||
}
|
||
|
||
interface Props {
|
||
users: User[];
|
||
currentUserId: number;
|
||
}
|
||
|
||
export default function AnvandareClient({ users: initial, currentUserId }: Props) {
|
||
const [users, setUsers] = useState<User[]>(initial);
|
||
const [error, setError] = useState('');
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [creating, setCreating] = useState(false);
|
||
const [createForm, setCreateForm] = useState({
|
||
username: '',
|
||
email: '',
|
||
password: '',
|
||
role: 'user',
|
||
});
|
||
const [createError, setCreateError] = useState('');
|
||
|
||
// Lösenordsåterställning modal
|
||
const [resetResult, setResetResult] = useState<ResetResult | null>(null);
|
||
const [copiedBody, setCopiedBody] = useState(false);
|
||
const [copiedPw, setCopiedPw] = useState(false);
|
||
|
||
// Inline e-postbyte
|
||
const [editingEmailId, setEditingEmailId] = useState<number | null>(null);
|
||
const [editingEmail, setEditingEmail] = useState('');
|
||
|
||
async function handleCreate(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setCreating(true);
|
||
setCreateError('');
|
||
try {
|
||
const res = await fetch('/api/admin-users', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(createForm),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.message ?? 'Kunde inte skapa användare');
|
||
setUsers((prev) => [...prev, data]);
|
||
setShowCreate(false);
|
||
setCreateForm({ username: '', email: '', password: '', role: 'user' });
|
||
} catch (err: any) {
|
||
setCreateError(err.message);
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: number) {
|
||
if (!confirm('Är du säker på att du vill ta bort användaren?')) return;
|
||
setError('');
|
||
const res = await fetch(`/api/admin-users/${id}`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||
} else {
|
||
const data = await res.json().catch(() => ({}));
|
||
setError(data.message ?? 'Kunde inte ta bort användaren');
|
||
}
|
||
}
|
||
|
||
async function handleRoleChange(id: number, role: string) {
|
||
setError('');
|
||
const res = await fetch(`/api/admin-users/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ role }),
|
||
});
|
||
if (res.ok) {
|
||
const updated = await res.json();
|
||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, role: updated.role } : u)));
|
||
} else {
|
||
const data = await res.json().catch(() => ({}));
|
||
setError(data.message ?? 'Kunde inte ändra roll');
|
||
}
|
||
}
|
||
|
||
async function handleResetPassword(id: number) {
|
||
setError('');
|
||
const res = await fetch(`/api/admin-users/${id}/reset-password`, { method: 'POST' });
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
setError(data.message ?? 'Kunde inte återställa lösenord');
|
||
return;
|
||
}
|
||
setResetResult(data);
|
||
setCopiedBody(false);
|
||
setCopiedPw(false);
|
||
}
|
||
|
||
async function handlePremiumChange(id: number, isPremium: boolean) {
|
||
setError('');
|
||
const res = await fetch(`/api/admin-users/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ isPremium }),
|
||
});
|
||
if (res.ok) {
|
||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, isPremium } : u)));
|
||
} else {
|
||
const data = await res.json().catch(() => ({}));
|
||
setError(data.message ?? 'Kunde inte ändra plan');
|
||
}
|
||
}
|
||
|
||
async function handleEmailSave(id: number) {
|
||
setError('');
|
||
const res = await fetch(`/api/admin-users/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email: editingEmail }),
|
||
});
|
||
if (res.ok) {
|
||
const updated = await res.json();
|
||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, email: updated.email } : u)));
|
||
setEditingEmailId(null);
|
||
} else {
|
||
const data = await res.json().catch(() => ({}));
|
||
setError(data.message ?? 'Kunde inte uppdatera e-post');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* Lägg till användare */}
|
||
<div style={{ marginBottom: '1.5rem' }}>
|
||
<button
|
||
onClick={() => setShowCreate((v) => !v)}
|
||
style={{
|
||
background: '#2563eb',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 6,
|
||
padding: '0.5rem 1.2rem',
|
||
cursor: 'pointer',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{showCreate ? '− Stäng' : '+ Lägg till användare'}
|
||
</button>
|
||
|
||
{showCreate && (
|
||
<form
|
||
onSubmit={handleCreate}
|
||
style={{
|
||
marginTop: '1rem',
|
||
background: '#f8fafc',
|
||
border: '1px solid #e2e8f0',
|
||
borderRadius: 8,
|
||
padding: '1rem 1.5rem',
|
||
maxWidth: 420,
|
||
}}
|
||
>
|
||
<h3 style={{ marginTop: 0, marginBottom: '1rem' }}>Ny användare</h3>
|
||
{createError && (
|
||
<div style={{ color: '#dc2626', marginBottom: '0.75rem', fontSize: 14 }}>
|
||
{createError}
|
||
</div>
|
||
)}
|
||
{[
|
||
{ key: 'username', label: 'Användarnamn', type: 'text' },
|
||
{ key: 'email', label: 'E-post', type: 'email' },
|
||
{ key: 'password', label: 'Lösenord', type: 'password' },
|
||
].map(({ key, label, type }) => (
|
||
<div key={key} style={{ marginBottom: '0.75rem' }}>
|
||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>{label}</label>
|
||
<input
|
||
type={type}
|
||
value={(createForm as any)[key]}
|
||
onChange={(e) => setCreateForm((f) => ({ ...f, [key]: e.target.value }))}
|
||
required
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.4rem 0.6rem',
|
||
border: '1px solid #cbd5e1',
|
||
borderRadius: 4,
|
||
fontSize: 14,
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>Roll</label>
|
||
<select
|
||
value={createForm.role}
|
||
onChange={(e) => setCreateForm((f) => ({ ...f, role: e.target.value }))}
|
||
style={{
|
||
padding: '0.4rem 0.6rem',
|
||
border: '1px solid #cbd5e1',
|
||
borderRadius: 4,
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
<option value="user">Användare</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
disabled={creating}
|
||
style={{
|
||
background: '#16a34a',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 6,
|
||
padding: '0.5rem 1.2rem',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{creating ? 'Skapar...' : 'Skapa'}
|
||
</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<div
|
||
style={{
|
||
background: '#fef2f2',
|
||
border: '1px solid #fecaca',
|
||
borderRadius: 6,
|
||
padding: '0.6rem 1rem',
|
||
color: '#dc2626',
|
||
marginBottom: '1rem',
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Användartabell */}
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||
<thead>
|
||
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||
{['Användare', 'E-post', 'Roll', 'Plan', 'Åtgärder'].map((h) => (
|
||
<th
|
||
key={h}
|
||
style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((user) => {
|
||
const isSelf = user.id === currentUserId;
|
||
const isEditingEmail = editingEmailId === user.id;
|
||
return (
|
||
<tr
|
||
key={user.id}
|
||
style={{ borderBottom: '1px solid #e2e8f0', background: isSelf ? '#f0f9ff' : 'transparent' }}
|
||
>
|
||
{/* Namn */}
|
||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||
{(user.firstName || user.lastName) && (
|
||
<div style={{ color: '#64748b', fontSize: 12 }}>
|
||
{[user.firstName, user.lastName].filter(Boolean).join(' ')}
|
||
</div>
|
||
)}
|
||
{isSelf && (
|
||
<span style={{ fontSize: 11, color: '#2563eb', fontStyle: 'italic' }}>
|
||
Du själv
|
||
</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* E-post */}
|
||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||
{isEditingEmail ? (
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<input
|
||
type="email"
|
||
value={editingEmail}
|
||
onChange={(e) => setEditingEmail(e.target.value)}
|
||
style={{
|
||
padding: '0.3rem 0.5rem',
|
||
border: '1px solid #93c5fd',
|
||
borderRadius: 4,
|
||
fontSize: 13,
|
||
width: 180,
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => handleEmailSave(user.id)}
|
||
style={{
|
||
background: '#16a34a',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '0.2rem 0.5rem',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
Spara
|
||
</button>
|
||
<button
|
||
onClick={() => setEditingEmailId(null)}
|
||
style={{
|
||
background: '#e2e8f0',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '0.2rem 0.5rem',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
Avbryt
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ color: '#334155' }}>{user.email}</span>
|
||
{!isSelf && (
|
||
<button
|
||
onClick={() => {
|
||
setEditingEmailId(user.id);
|
||
setEditingEmail(user.email);
|
||
}}
|
||
title="Ändra e-post"
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
color: '#64748b',
|
||
fontSize: 13,
|
||
padding: 0,
|
||
}}
|
||
>
|
||
✎
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* Roll */}
|
||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||
{isSelf ? (
|
||
<RoleBadge role={user.role} />
|
||
) : (
|
||
<select
|
||
value={user.role}
|
||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||
style={{
|
||
padding: '0.25rem 0.4rem',
|
||
border: '1px solid #cbd5e1',
|
||
borderRadius: 4,
|
||
fontSize: 13,
|
||
background: user.role === 'admin' ? '#eff6ff' : '#f8fafc',
|
||
}}
|
||
>
|
||
<option value="user">Användare</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
)}
|
||
</td>
|
||
|
||
{/* Plan */}
|
||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||
<select
|
||
value={user.isPremium ? 'paid' : 'free'}
|
||
onChange={(e) => handlePremiumChange(user.id, e.target.value === 'paid')}
|
||
style={{
|
||
padding: '0.25rem 0.4rem',
|
||
border: '1px solid #cbd5e1',
|
||
borderRadius: 4,
|
||
fontSize: 13,
|
||
background: user.isPremium ? '#fef9c3' : '#f8fafc',
|
||
color: user.isPremium ? '#854d0e' : '#334155',
|
||
fontWeight: user.isPremium ? 600 : 400,
|
||
}}
|
||
>
|
||
<option value="free">Free</option>
|
||
<option value="paid">Paid ✨</option>
|
||
</select>
|
||
</td>
|
||
|
||
{/* Åtgärder */}
|
||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||
{isSelf ? (
|
||
<span style={{ color: '#94a3b8', fontSize: 12 }}>—</span>
|
||
) : (
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<button
|
||
onClick={() => handleResetPassword(user.id)}
|
||
title="Återställ lösenord"
|
||
style={{
|
||
background: '#fef3c7',
|
||
border: '1px solid #fcd34d',
|
||
borderRadius: 4,
|
||
padding: '0.3rem 0.6rem',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
color: '#92400e',
|
||
}}
|
||
>
|
||
Återställ lösenord
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(user.id)}
|
||
title="Ta bort"
|
||
style={{
|
||
background: '#fef2f2',
|
||
border: '1px solid #fecaca',
|
||
borderRadius: 4,
|
||
padding: '0.3rem 0.6rem',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
color: '#dc2626',
|
||
}}
|
||
>
|
||
Ta bort
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Lösenordsåterställning modal */}
|
||
{resetResult && (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
background: 'rgba(0,0,0,0.45)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1000,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 10,
|
||
padding: '1.5rem 2rem',
|
||
maxWidth: 520,
|
||
width: '90%',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||
}}
|
||
>
|
||
<h3 style={{ marginTop: 0 }}>Lösenordet har återställts</h3>
|
||
<p style={{ fontSize: 13, color: '#475569' }}>
|
||
Skicka nedanstående meddelande till användaren och/eller ge dem det tillfälliga lösenordet.
|
||
</p>
|
||
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||
<label style={{ fontSize: 13, fontWeight: 500 }}>Meddelande att skicka till användaren</label>
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(resetResult.body);
|
||
setCopiedBody(true);
|
||
}}
|
||
style={{
|
||
background: copiedBody ? '#dcfce7' : '#f1f5f9',
|
||
border: '1px solid #cbd5e1',
|
||
borderRadius: 4,
|
||
padding: '0.2rem 0.6rem',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
color: copiedBody ? '#16a34a' : '#334155',
|
||
}}
|
||
>
|
||
{copiedBody ? '✓ Kopierat' : 'Kopiera meddelande'}
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
readOnly
|
||
value={resetResult.body}
|
||
rows={6}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.5rem',
|
||
fontSize: 13,
|
||
fontFamily: 'monospace',
|
||
border: '1px solid #e2e8f0',
|
||
borderRadius: 6,
|
||
background: '#f8fafc',
|
||
resize: 'vertical',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '1.5rem' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||
<label style={{ fontSize: 13, fontWeight: 500 }}>Tillfälligt lösenord</label>
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(resetResult.temporaryPassword);
|
||
setCopiedPw(true);
|
||
}}
|
||
style={{
|
||
background: copiedPw ? '#dcfce7' : '#f1f5f9',
|
||
border: '1px solid #cbd5e1',
|
||
borderRadius: 4,
|
||
padding: '0.2rem 0.6rem',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
color: copiedPw ? '#16a34a' : '#334155',
|
||
}}
|
||
>
|
||
{copiedPw ? '✓ Kopierat' : 'Kopiera lösenord'}
|
||
</button>
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: 'monospace',
|
||
fontSize: 18,
|
||
fontWeight: 700,
|
||
letterSpacing: 2,
|
||
padding: '0.5rem 1rem',
|
||
background: '#fff7ed',
|
||
border: '1px solid #fed7aa',
|
||
borderRadius: 6,
|
||
color: '#9a3412',
|
||
userSelect: 'all',
|
||
}}
|
||
>
|
||
{resetResult.temporaryPassword}
|
||
</div>
|
||
</div>
|
||
|
||
{!copiedBody && !copiedPw && (
|
||
<div
|
||
style={{
|
||
background: '#fefce8',
|
||
border: '1px solid #fde68a',
|
||
borderRadius: 6,
|
||
padding: '0.5rem 0.75rem',
|
||
fontSize: 12,
|
||
color: '#92400e',
|
||
marginBottom: '1rem',
|
||
}}
|
||
>
|
||
⚠ Kopiera lösenordet eller meddelandet innan du stänger — det visas inte igen.
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => setResetResult(null)}
|
||
style={{
|
||
background: '#e2e8f0',
|
||
border: 'none',
|
||
borderRadius: 6,
|
||
padding: '0.5rem 1.2rem',
|
||
cursor: 'pointer',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Stäng
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RoleBadge({ role }: { role: string }) {
|
||
return (
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
padding: '0.2rem 0.6rem',
|
||
borderRadius: 12,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
background: role === 'admin' ? '#eff6ff' : '#f0fdf4',
|
||
color: role === 'admin' ? '#1d4ed8' : '#15803d',
|
||
border: `1px solid ${role === 'admin' ? '#bfdbfe' : '#bbf7d0'}`,
|
||
}}
|
||
>
|
||
{role === 'admin' ? 'Admin' : 'Användare'}
|
||
</span>
|
||
);
|
||
}
|