mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
feat(auth): add admin login token management
This commit is contained in:
@@ -29,6 +29,7 @@ interface User {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
_count: {
|
_count: {
|
||||||
requests: number;
|
requests: number;
|
||||||
};
|
};
|
||||||
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
|
|||||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleToken = async (user: User, newValue: boolean) => {
|
||||||
|
try {
|
||||||
|
if (newValue) {
|
||||||
|
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
|
||||||
|
setGeneratedToken(result.fullToken);
|
||||||
|
toast.success(`Login token generated for ${user.plexUsername}`);
|
||||||
|
} else {
|
||||||
|
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
|
||||||
|
setGeneratedToken(null);
|
||||||
|
toast.success(`Login token revoked for ${user.plexUsername}`);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showEditDialog = (user: User) => {
|
const showEditDialog = (user: User) => {
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditDialog({ isOpen: true, user });
|
setEditDialog({ isOpen: true, user });
|
||||||
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
|
|||||||
{/* User Permissions Modal */}
|
{/* User Permissions Modal */}
|
||||||
<UserPermissionsModal
|
<UserPermissionsModal
|
||||||
isOpen={permissionsUser !== null}
|
isOpen={permissionsUser !== null}
|
||||||
onClose={() => setPermissionsUserId(null)}
|
onClose={() => {
|
||||||
|
setPermissionsUserId(null);
|
||||||
|
setGeneratedToken(null);
|
||||||
|
}}
|
||||||
user={permissionsUser}
|
user={permissionsUser}
|
||||||
globalAutoApprove={globalAutoApprove}
|
globalAutoApprove={globalAutoApprove}
|
||||||
globalInteractiveSearch={globalInteractiveSearch}
|
globalInteractiveSearch={globalInteractiveSearch}
|
||||||
globalDownloadAccess={globalDownloadAccess}
|
globalDownloadAccess={globalDownloadAccess}
|
||||||
|
generatedToken={generatedToken}
|
||||||
onToggleAutoApprove={(user, newValue) => {
|
onToggleAutoApprove={(user, newValue) => {
|
||||||
handleUserAutoApproveToggle(user as User, newValue);
|
handleUserAutoApproveToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
|
|||||||
onToggleDownloadAccess={(user, newValue) => {
|
onToggleDownloadAccess={(user, newValue) => {
|
||||||
handleUserDownloadAccessToggle(user as User, newValue);
|
handleUserDownloadAccessToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
|
onToggleToken={(user, newValue) => {
|
||||||
|
handleToggleToken(user as unknown as User, newValue);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin User Login Token
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.deletedAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot generate token for deleted user' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullToken } = generateApiToken();
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: tokenHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin generated login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
createdBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ fullToken }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin revoked login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
revokedBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to revoke login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
autoApproveRequests: true,
|
autoApproveRequests: true,
|
||||||
interactiveSearchAccess: true,
|
interactiveSearchAccess: true,
|
||||||
downloadAccess: true,
|
downloadAccess: true,
|
||||||
|
loginTokenHash: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
requests: true,
|
requests: true,
|
||||||
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
return NextResponse.json({
|
||||||
|
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||||
|
...u,
|
||||||
|
hasLoginToken: loginTokenHash !== null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface UserPermissionsUser {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserPermissionsModalProps {
|
interface UserPermissionsModalProps {
|
||||||
@@ -25,9 +26,11 @@ interface UserPermissionsModalProps {
|
|||||||
globalAutoApprove: boolean;
|
globalAutoApprove: boolean;
|
||||||
globalInteractiveSearch: boolean;
|
globalInteractiveSearch: boolean;
|
||||||
globalDownloadAccess: boolean;
|
globalDownloadAccess: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
|
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionToggleProps {
|
interface PermissionToggleProps {
|
||||||
@@ -83,6 +86,78 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoginTokenRowProps {
|
||||||
|
value: boolean;
|
||||||
|
generatedToken: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
|
||||||
|
const loginUrl = generatedToken
|
||||||
|
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!loginUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(loginUrl);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||||
|
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value}
|
||||||
|
aria-label="Login Token"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
value ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Login Token
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, this user can log in via a direct URL without credentials
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginUrl && (
|
||||||
|
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
|
||||||
|
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||||
|
Copy the login URL - it won't be shown again
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
|
||||||
|
{loginUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyUrl}
|
||||||
|
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
|
||||||
|
aria-label="Copy login URL"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UserPermissionsModal({
|
export function UserPermissionsModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -90,9 +165,11 @@ export function UserPermissionsModal({
|
|||||||
globalAutoApprove,
|
globalAutoApprove,
|
||||||
globalInteractiveSearch,
|
globalInteractiveSearch,
|
||||||
globalDownloadAccess,
|
globalDownloadAccess,
|
||||||
|
generatedToken,
|
||||||
onToggleAutoApprove,
|
onToggleAutoApprove,
|
||||||
onToggleInteractiveSearch,
|
onToggleInteractiveSearch,
|
||||||
onToggleDownloadAccess,
|
onToggleDownloadAccess,
|
||||||
|
onToggleToken,
|
||||||
}: UserPermissionsModalProps) {
|
}: UserPermissionsModalProps) {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
@@ -201,6 +278,13 @@ export function UserPermissionsModal({
|
|||||||
description="When enabled, this user can download audiobook files directly"
|
description="When enabled, this user can download audiobook files directly"
|
||||||
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Login Token */}
|
||||||
|
<LoginTokenRow
|
||||||
|
value={user.hasLoginToken || generatedToken !== null}
|
||||||
|
generatedToken={generatedToken}
|
||||||
|
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user