Poki01 commited on
Commit ·
3bbb98d
1
Parent(s): 56181a0
Require admin token for private access
Browse files- apps/backend/src/app.module.ts +2 -1
- apps/backend/src/clipboard/clipboard.gateway.ts +21 -0
- apps/backend/src/common/auth.controller.ts +9 -0
- apps/backend/src/main.ts +19 -3
- apps/frontend/src/app/[roomCode]/components/FileList.tsx +2 -1
- apps/frontend/src/app/[roomCode]/components/FilePreview.tsx +5 -4
- apps/frontend/src/app/[roomCode]/components/FileUploadComponent.tsx +7 -1
- apps/frontend/src/app/[roomCode]/page.tsx +9 -3
- apps/frontend/src/app/admin/[roomCode]/page.tsx +2 -1
- apps/frontend/src/app/layout.tsx +13 -10
- apps/frontend/src/components/AdminAccessGate.tsx +103 -0
- apps/frontend/src/components/CreateClipboardCard.tsx +3 -2
- apps/frontend/src/components/JoinClipboardCard.tsx +6 -1
- apps/frontend/src/components/PasswordModal.tsx +59 -13
- apps/frontend/src/lib/adminAuth.ts +36 -0
- apps/frontend/src/lib/hooks/useSavedClipboards.ts +8 -2
- apps/frontend/src/lib/hooks/useSocketManager.ts +13 -2
apps/backend/src/app.module.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { RedisModule } from './common/redis/redis.module';
|
|
| 4 |
import { FileModule } from './file/file.module';
|
| 5 |
import { FilesystemModule } from './common/filesystem/filesystem.module';
|
| 6 |
import { AdminModule } from './admin/admin.module';
|
|
|
|
| 7 |
|
| 8 |
@Module({
|
| 9 |
imports: [
|
|
@@ -13,7 +14,7 @@ import { AdminModule } from './admin/admin.module';
|
|
| 13 |
FilesystemModule,
|
| 14 |
AdminModule,
|
| 15 |
],
|
| 16 |
-
controllers: [],
|
| 17 |
providers: [],
|
| 18 |
})
|
| 19 |
export class AppModule {}
|
|
|
|
| 4 |
import { FileModule } from './file/file.module';
|
| 5 |
import { FilesystemModule } from './common/filesystem/filesystem.module';
|
| 6 |
import { AdminModule } from './admin/admin.module';
|
| 7 |
+
import { AuthController } from './common/auth.controller';
|
| 8 |
|
| 9 |
@Module({
|
| 10 |
imports: [
|
|
|
|
| 14 |
FilesystemModule,
|
| 15 |
AdminModule,
|
| 16 |
],
|
| 17 |
+
controllers: [AuthController],
|
| 18 |
providers: [],
|
| 19 |
})
|
| 20 |
export class AppModule {}
|
apps/backend/src/clipboard/clipboard.gateway.ts
CHANGED
|
@@ -29,6 +29,7 @@ interface RoomUserCount {
|
|
| 29 |
export class ClipboardGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
| 30 |
private readonly logger = new Logger(ClipboardGateway.name);
|
| 31 |
private roomUserCount: RoomUserCount = {};
|
|
|
|
| 32 |
|
| 33 |
@WebSocketServer()
|
| 34 |
server: Server;
|
|
@@ -40,6 +41,15 @@ export class ClipboardGateway implements OnGatewayInit, OnGatewayConnection, OnG
|
|
| 40 |
}
|
| 41 |
|
| 42 |
async handleConnection(client: Socket) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
const { roomCode } = client.handshake.query;
|
| 44 |
|
| 45 |
if (!roomCode || typeof roomCode !== 'string') {
|
|
@@ -284,4 +294,15 @@ export class ClipboardGateway implements OnGatewayInit, OnGatewayConnection, OnG
|
|
| 284 |
|
| 285 |
this.logger.log(`File ${fileId} deleted from room ${roomCode}`);
|
| 286 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
}
|
|
|
|
| 29 |
export class ClipboardGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
| 30 |
private readonly logger = new Logger(ClipboardGateway.name);
|
| 31 |
private roomUserCount: RoomUserCount = {};
|
| 32 |
+
private readonly expectedToken = process.env.ADMIN_TOKEN || 'admin-token';
|
| 33 |
|
| 34 |
@WebSocketServer()
|
| 35 |
server: Server;
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
async handleConnection(client: Socket) {
|
| 44 |
+
const token = this.getClientToken(client);
|
| 45 |
+
|
| 46 |
+
if (token !== this.expectedToken) {
|
| 47 |
+
this.logger.warn(`Client ${client.id} attempted to connect with invalid token.`);
|
| 48 |
+
client.emit('error', { message: 'Unauthorized connection.' });
|
| 49 |
+
client.disconnect();
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
const { roomCode } = client.handshake.query;
|
| 54 |
|
| 55 |
if (!roomCode || typeof roomCode !== 'string') {
|
|
|
|
| 294 |
|
| 295 |
this.logger.log(`File ${fileId} deleted from room ${roomCode}`);
|
| 296 |
}
|
| 297 |
+
|
| 298 |
+
private getClientToken(client: Socket): string | string[] | undefined {
|
| 299 |
+
const headerToken = client.handshake.headers['x-admin-token'] || client.handshake.headers['authorization'];
|
| 300 |
+
const queryToken = client.handshake.query['token'];
|
| 301 |
+
|
| 302 |
+
if (typeof headerToken === 'string' && headerToken.startsWith('Bearer ')) {
|
| 303 |
+
return headerToken.slice(7);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
return headerToken || queryToken;
|
| 307 |
+
}
|
| 308 |
}
|
apps/backend/src/common/auth.controller.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Controller, Get } from '@nestjs/common';
|
| 2 |
+
|
| 3 |
+
@Controller('auth')
|
| 4 |
+
export class AuthController {
|
| 5 |
+
@Get('verify')
|
| 6 |
+
verify() {
|
| 7 |
+
return { status: 'ok' };
|
| 8 |
+
}
|
| 9 |
+
}
|
apps/backend/src/main.ts
CHANGED
|
@@ -23,6 +23,11 @@ class CustomIoAdapter extends IoAdapter {
|
|
| 23 |
async function bootstrap() {
|
| 24 |
const logger = new Logger('Bootstrap');
|
| 25 |
const app = await NestFactory.create(AppModule);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
// Enable CORS for HTTP requests
|
| 28 |
app.enableCors({
|
|
@@ -30,6 +35,20 @@ async function bootstrap() {
|
|
| 30 |
methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'],
|
| 31 |
credentials: true,
|
| 32 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
app.use("*", (req: Request, _, next: NextFunction) => {
|
| 34 |
console.log(req.method, req.baseUrl)
|
| 35 |
next()
|
|
@@ -38,9 +57,6 @@ async function bootstrap() {
|
|
| 38 |
// Use custom adapter for WebSockets
|
| 39 |
app.useWebSocketAdapter(new CustomIoAdapter(app));
|
| 40 |
|
| 41 |
-
// Set global prefix for REST API
|
| 42 |
-
app.setGlobalPrefix('api');
|
| 43 |
-
|
| 44 |
|
| 45 |
const port = process.env.PORT || 3001;
|
| 46 |
await app.listen(port);
|
|
|
|
| 23 |
async function bootstrap() {
|
| 24 |
const logger = new Logger('Bootstrap');
|
| 25 |
const app = await NestFactory.create(AppModule);
|
| 26 |
+
const expectedToken = process.env.ADMIN_TOKEN || 'admin-token';
|
| 27 |
+
|
| 28 |
+
// Set global prefix for REST API early so middleware matches the full path
|
| 29 |
+
const apiPrefix = 'api';
|
| 30 |
+
app.setGlobalPrefix(apiPrefix);
|
| 31 |
|
| 32 |
// Enable CORS for HTTP requests
|
| 33 |
app.enableCors({
|
|
|
|
| 35 |
methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'],
|
| 36 |
credentials: true,
|
| 37 |
});
|
| 38 |
+
app.use(`/${apiPrefix}`, (req: Request, res, next: NextFunction) => {
|
| 39 |
+
const headerToken = req.headers['x-admin-token'] || req.headers['authorization'];
|
| 40 |
+
const queryToken = req.query['token'];
|
| 41 |
+
const token = typeof headerToken === 'string' && headerToken.startsWith('Bearer ')
|
| 42 |
+
? headerToken.slice(7)
|
| 43 |
+
: headerToken || queryToken;
|
| 44 |
+
|
| 45 |
+
if (token === expectedToken) {
|
| 46 |
+
return next();
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return res.status(401).json({ message: 'Invalid admin token' });
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
app.use("*", (req: Request, _, next: NextFunction) => {
|
| 53 |
console.log(req.method, req.baseUrl)
|
| 54 |
next()
|
|
|
|
| 57 |
// Use custom adapter for WebSockets
|
| 58 |
app.useWebSocketAdapter(new CustomIoAdapter(app));
|
| 59 |
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
const port = process.env.PORT || 3001;
|
| 62 |
await app.listen(port);
|
apps/frontend/src/app/[roomCode]/components/FileList.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { FileIcon, Image, FileText, Download, Trash2, Eye, AlertCircle } from 'l
|
|
| 5 |
import { apiUrl } from '@/lib/constants';
|
| 6 |
import FilePreview from './FilePreview';
|
| 7 |
import { isPreviewable, formatFileSize } from '@/lib/utils/fileUtils';
|
|
|
|
| 8 |
|
| 9 |
interface FileEntry {
|
| 10 |
id: string;
|
|
@@ -113,7 +114,7 @@ export default function FileList({ files, onDeleteFile, roomCode }: FileListProp
|
|
| 113 |
)}
|
| 114 |
<a
|
| 115 |
onClick={(e) => e.stopPropagation()}
|
| 116 |
-
href={`${apiUrl}/clipboard/${roomCode}/files/${file.id}?filename=${encodeURIComponent(file.filename)}`}
|
| 117 |
download={file.filename}
|
| 118 |
className="p-1.5 text-text-secondary hover:text-primary rounded-md hover:bg-primary/10 transition-colors"
|
| 119 |
title="Download"
|
|
|
|
| 5 |
import { apiUrl } from '@/lib/constants';
|
| 6 |
import FilePreview from './FilePreview';
|
| 7 |
import { isPreviewable, formatFileSize } from '@/lib/utils/fileUtils';
|
| 8 |
+
import { appendTokenToUrl } from '@/lib/adminAuth';
|
| 9 |
|
| 10 |
interface FileEntry {
|
| 11 |
id: string;
|
|
|
|
| 114 |
)}
|
| 115 |
<a
|
| 116 |
onClick={(e) => e.stopPropagation()}
|
| 117 |
+
href={appendTokenToUrl(`${apiUrl}/clipboard/${roomCode}/files/${file.id}?filename=${encodeURIComponent(file.filename)}`)}
|
| 118 |
download={file.filename}
|
| 119 |
className="p-1.5 text-text-secondary hover:text-primary rounded-md hover:bg-primary/10 transition-colors"
|
| 120 |
title="Download"
|
apps/frontend/src/app/[roomCode]/components/FilePreview.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import { X } from 'lucide-react';
|
| 5 |
import { apiUrl } from '@/lib/constants';
|
|
|
|
| 6 |
|
| 7 |
interface FilePreviewProps {
|
| 8 |
fileId: string;
|
|
@@ -14,7 +15,7 @@ interface FilePreviewProps {
|
|
| 14 |
|
| 15 |
export default function FilePreview({ fileId, filename, mimetype, roomCode, onClose }: FilePreviewProps) {
|
| 16 |
const [loading, setLoading] = useState(true);
|
| 17 |
-
const fileUrl = `${apiUrl}/clipboard/${roomCode}/files/${fileId}`;
|
| 18 |
|
| 19 |
// Handle escape key to close preview
|
| 20 |
useEffect(() => {
|
|
@@ -88,7 +89,7 @@ export default function FilePreview({ fileId, filename, mimetype, roomCode, onCl
|
|
| 88 |
<p className="text-text-primary text-xl mb-4">Preview not available</p>
|
| 89 |
<p className="text-text-secondary mb-6">This file type cannot be previewed.</p>
|
| 90 |
<a
|
| 91 |
-
href={`${apiUrl}/clipboard/${roomCode}/files/${fileId}?filename=${encodeURIComponent(filename)}`}
|
| 92 |
target="_blank"
|
| 93 |
rel="noopener noreferrer"
|
| 94 |
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
|
|
@@ -132,7 +133,7 @@ export default function FilePreview({ fileId, filename, mimetype, roomCode, onCl
|
|
| 132 |
{/* Footer */}
|
| 133 |
<div className="p-4 border-t border-surface-hover flex justify-end">
|
| 134 |
<a
|
| 135 |
-
href={`${apiUrl}/clipboard/${roomCode}/files/${fileId}?filename=${encodeURIComponent(filename)}`}
|
| 136 |
download={filename}
|
| 137 |
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
|
| 138 |
>
|
|
@@ -152,7 +153,7 @@ function TextFilePreview({ fileUrl, setLoading }: { fileUrl: string; setLoading:
|
|
| 152 |
useEffect(() => {
|
| 153 |
async function fetchTextContent() {
|
| 154 |
try {
|
| 155 |
-
const response = await fetch(fileUrl);
|
| 156 |
if (!response.ok) {
|
| 157 |
throw new Error(`Failed to fetch file: ${response.status}`);
|
| 158 |
}
|
|
|
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import { X } from 'lucide-react';
|
| 5 |
import { apiUrl } from '@/lib/constants';
|
| 6 |
+
import { appendTokenToUrl, withAdminTokenHeader } from '@/lib/adminAuth';
|
| 7 |
|
| 8 |
interface FilePreviewProps {
|
| 9 |
fileId: string;
|
|
|
|
| 15 |
|
| 16 |
export default function FilePreview({ fileId, filename, mimetype, roomCode, onClose }: FilePreviewProps) {
|
| 17 |
const [loading, setLoading] = useState(true);
|
| 18 |
+
const fileUrl = appendTokenToUrl(`${apiUrl}/clipboard/${roomCode}/files/${fileId}`);
|
| 19 |
|
| 20 |
// Handle escape key to close preview
|
| 21 |
useEffect(() => {
|
|
|
|
| 89 |
<p className="text-text-primary text-xl mb-4">Preview not available</p>
|
| 90 |
<p className="text-text-secondary mb-6">This file type cannot be previewed.</p>
|
| 91 |
<a
|
| 92 |
+
href={appendTokenToUrl(`${apiUrl}/clipboard/${roomCode}/files/${fileId}?filename=${encodeURIComponent(filename)}`)}
|
| 93 |
target="_blank"
|
| 94 |
rel="noopener noreferrer"
|
| 95 |
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
|
|
|
|
| 133 |
{/* Footer */}
|
| 134 |
<div className="p-4 border-t border-surface-hover flex justify-end">
|
| 135 |
<a
|
| 136 |
+
href={appendTokenToUrl(`${apiUrl}/clipboard/${roomCode}/files/${fileId}?filename=${encodeURIComponent(filename)}`)}
|
| 137 |
download={filename}
|
| 138 |
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
|
| 139 |
>
|
|
|
|
| 153 |
useEffect(() => {
|
| 154 |
async function fetchTextContent() {
|
| 155 |
try {
|
| 156 |
+
const response = await fetch(fileUrl, { headers: withAdminTokenHeader() });
|
| 157 |
if (!response.ok) {
|
| 158 |
throw new Error(`Failed to fetch file: ${response.status}`);
|
| 159 |
}
|
apps/frontend/src/app/[roomCode]/components/FileUploadComponent.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import { useState, useRef } from 'react';
|
| 4 |
import { Upload, X } from 'lucide-react';
|
| 5 |
import { apiUrl } from '@/lib/constants';
|
|
|
|
| 6 |
|
| 7 |
interface FileUploadProps {
|
| 8 |
roomCode: string;
|
|
@@ -58,6 +59,8 @@ export default function FileUploadComponent({ roomCode, clientId, onFileUploaded
|
|
| 58 |
|
| 59 |
try {
|
| 60 |
const xhr = new XMLHttpRequest();
|
|
|
|
|
|
|
| 61 |
|
| 62 |
xhr.upload.onprogress = (event) => {
|
| 63 |
if (event.lengthComputable) {
|
|
@@ -88,7 +91,10 @@ export default function FileUploadComponent({ roomCode, clientId, onFileUploaded
|
|
| 88 |
setIsUploading(false);
|
| 89 |
};
|
| 90 |
|
| 91 |
-
xhr.open('POST',
|
|
|
|
|
|
|
|
|
|
| 92 |
xhr.send(formData);
|
| 93 |
|
| 94 |
} catch (err: any) {
|
|
|
|
| 3 |
import { useState, useRef } from 'react';
|
| 4 |
import { Upload, X } from 'lucide-react';
|
| 5 |
import { apiUrl } from '@/lib/constants';
|
| 6 |
+
import { appendTokenToUrl, getAdminToken } from '@/lib/adminAuth';
|
| 7 |
|
| 8 |
interface FileUploadProps {
|
| 9 |
roomCode: string;
|
|
|
|
| 59 |
|
| 60 |
try {
|
| 61 |
const xhr = new XMLHttpRequest();
|
| 62 |
+
const token = getAdminToken();
|
| 63 |
+
const uploadUrl = appendTokenToUrl(`${apiUrl}/clipboard/${roomCode}/files`);
|
| 64 |
|
| 65 |
xhr.upload.onprogress = (event) => {
|
| 66 |
if (event.lengthComputable) {
|
|
|
|
| 91 |
setIsUploading(false);
|
| 92 |
};
|
| 93 |
|
| 94 |
+
xhr.open('POST', uploadUrl, true);
|
| 95 |
+
if (token) {
|
| 96 |
+
xhr.setRequestHeader('x-admin-token', token);
|
| 97 |
+
}
|
| 98 |
xhr.send(formData);
|
| 99 |
|
| 100 |
} catch (err: any) {
|
apps/frontend/src/app/[roomCode]/page.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { FileEntryType } from './components/FileEntry';
|
|
| 19 |
import { useSocketManager } from '@/lib/hooks/useSocketManager';
|
| 20 |
import { useSavedClipboards } from '@/lib/hooks/useSavedClipboards';
|
| 21 |
import { apiUrl } from '@/lib/constants';
|
|
|
|
| 22 |
|
| 23 |
export default function ClipboardRoom() {
|
| 24 |
const params = useParams();
|
|
@@ -107,7 +108,12 @@ export default function ClipboardRoom() {
|
|
| 107 |
const checkPasswordProtection = async () => {
|
| 108 |
try {
|
| 109 |
setIsCheckingPassword(true);
|
| 110 |
-
const response = await fetch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
if (!response.ok) {
|
| 113 |
throw new Error('Failed to check clipboard');
|
|
@@ -144,9 +150,9 @@ export default function ClipboardRoom() {
|
|
| 144 |
try {
|
| 145 |
const response = await fetch(`${apiUrl}/clipboard/${roomCode}/verify`, {
|
| 146 |
method: 'POST',
|
| 147 |
-
headers: {
|
| 148 |
'Content-Type': 'application/json',
|
| 149 |
-
},
|
| 150 |
body: JSON.stringify({ password }),
|
| 151 |
});
|
| 152 |
|
|
|
|
| 19 |
import { useSocketManager } from '@/lib/hooks/useSocketManager';
|
| 20 |
import { useSavedClipboards } from '@/lib/hooks/useSavedClipboards';
|
| 21 |
import { apiUrl } from '@/lib/constants';
|
| 22 |
+
import { appendTokenToUrl, withAdminTokenHeader } from '@/lib/adminAuth';
|
| 23 |
|
| 24 |
export default function ClipboardRoom() {
|
| 25 |
const params = useParams();
|
|
|
|
| 108 |
const checkPasswordProtection = async () => {
|
| 109 |
try {
|
| 110 |
setIsCheckingPassword(true);
|
| 111 |
+
const response = await fetch(
|
| 112 |
+
appendTokenToUrl(`${apiUrl}/clipboard/${roomCode}/exists`),
|
| 113 |
+
{
|
| 114 |
+
headers: withAdminTokenHeader(),
|
| 115 |
+
},
|
| 116 |
+
);
|
| 117 |
|
| 118 |
if (!response.ok) {
|
| 119 |
throw new Error('Failed to check clipboard');
|
|
|
|
| 150 |
try {
|
| 151 |
const response = await fetch(`${apiUrl}/clipboard/${roomCode}/verify`, {
|
| 152 |
method: 'POST',
|
| 153 |
+
headers: withAdminTokenHeader({
|
| 154 |
'Content-Type': 'application/json',
|
| 155 |
+
}),
|
| 156 |
body: JSON.stringify({ password }),
|
| 157 |
});
|
| 158 |
|
apps/frontend/src/app/admin/[roomCode]/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|
| 4 |
import Link from 'next/link';
|
| 5 |
import { useParams, useRouter } from 'next/navigation';
|
| 6 |
import { apiUrl } from '@/lib/constants';
|
|
|
|
| 7 |
|
| 8 |
interface ClipboardEntry {
|
| 9 |
id: string;
|
|
@@ -303,7 +304,7 @@ export default function ClipboardAdminDetail() {
|
|
| 303 |
<p className="text-xs text-text-secondary">Uploaded {new Date(file.createdAt).toLocaleString()}</p>
|
| 304 |
</div>
|
| 305 |
<a
|
| 306 |
-
href={`${apiUrl}/files/${file.storageKey}`}
|
| 307 |
className="btn btn-ghost btn-sm"
|
| 308 |
target="_blank"
|
| 309 |
rel="noopener noreferrer"
|
|
|
|
| 4 |
import Link from 'next/link';
|
| 5 |
import { useParams, useRouter } from 'next/navigation';
|
| 6 |
import { apiUrl } from '@/lib/constants';
|
| 7 |
+
import { appendTokenToUrl } from '@/lib/adminAuth';
|
| 8 |
|
| 9 |
interface ClipboardEntry {
|
| 10 |
id: string;
|
|
|
|
| 304 |
<p className="text-xs text-text-secondary">Uploaded {new Date(file.createdAt).toLocaleString()}</p>
|
| 305 |
</div>
|
| 306 |
<a
|
| 307 |
+
href={appendTokenToUrl(`${apiUrl}/files/${file.storageKey}`)}
|
| 308 |
className="btn btn-ghost btn-sm"
|
| 309 |
target="_blank"
|
| 310 |
rel="noopener noreferrer"
|
apps/frontend/src/app/layout.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import '@/styles/globals.css';
|
| 2 |
import type { Metadata } from 'next';
|
| 3 |
import { ToastProvider } from '@/components/ui/Toast';
|
|
|
|
| 4 |
|
| 5 |
export const metadata: Metadata = {
|
| 6 |
title: 'Clipboard Online',
|
|
@@ -50,17 +51,19 @@ export default function RootLayout({
|
|
| 50 |
</head>
|
| 51 |
<body>
|
| 52 |
<ToastProvider>
|
| 53 |
-
<
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
<div className="
|
| 57 |
-
|
| 58 |
-
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 64 |
</ToastProvider>
|
| 65 |
</body>
|
| 66 |
</html>
|
|
|
|
| 1 |
import '@/styles/globals.css';
|
| 2 |
import type { Metadata } from 'next';
|
| 3 |
import { ToastProvider } from '@/components/ui/Toast';
|
| 4 |
+
import AdminAccessGate from '@/components/AdminAccessGate';
|
| 5 |
|
| 6 |
export const metadata: Metadata = {
|
| 7 |
title: 'Clipboard Online',
|
|
|
|
| 51 |
</head>
|
| 52 |
<body>
|
| 53 |
<ToastProvider>
|
| 54 |
+
<AdminAccessGate>
|
| 55 |
+
<div className="relative">
|
| 56 |
+
{/* Decorative elements */}
|
| 57 |
+
<div className="fixed inset-0 z-[-1] overflow-hidden">
|
| 58 |
+
<div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-gradient-radial from-primary/5 to-transparent opacity-30 blur-3xl" />
|
| 59 |
+
<div className="absolute -bottom-[10%] -right-[10%] w-[40%] h-[40%] bg-gradient-radial from-secondary/5 to-transparent opacity-30 blur-3xl" />
|
| 60 |
+
</div>
|
| 61 |
|
| 62 |
+
<main className="flex flex-col min-h-screen">
|
| 63 |
+
{children}
|
| 64 |
+
</main>
|
| 65 |
+
</div>
|
| 66 |
+
</AdminAccessGate>
|
| 67 |
</ToastProvider>
|
| 68 |
</body>
|
| 69 |
</html>
|
apps/frontend/src/components/AdminAccessGate.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { apiUrl } from '@/lib/constants';
|
| 5 |
+
import { getAdminToken, setAdminToken } from '@/lib/adminAuth';
|
| 6 |
+
|
| 7 |
+
interface AdminAccessGateProps {
|
| 8 |
+
children: React.ReactNode;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function AdminAccessGate({ children }: AdminAccessGateProps) {
|
| 12 |
+
const [tokenInput, setTokenInput] = useState('');
|
| 13 |
+
const [verified, setVerified] = useState(false);
|
| 14 |
+
const [loading, setLoading] = useState(false);
|
| 15 |
+
const [error, setError] = useState('');
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
const saved = getAdminToken();
|
| 19 |
+
if (saved) {
|
| 20 |
+
setTokenInput(saved);
|
| 21 |
+
verifyToken(saved);
|
| 22 |
+
}
|
| 23 |
+
}, []);
|
| 24 |
+
|
| 25 |
+
const verifyToken = async (tokenToVerify: string) => {
|
| 26 |
+
if (!tokenToVerify) {
|
| 27 |
+
setError('请输入 admin token');
|
| 28 |
+
return;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
setLoading(true);
|
| 32 |
+
setError('');
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
const response = await fetch(`${apiUrl}/auth/verify?token=${encodeURIComponent(tokenToVerify)}`, {
|
| 36 |
+
headers: { 'x-admin-token': tokenToVerify },
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
if (!response.ok) {
|
| 40 |
+
setVerified(false);
|
| 41 |
+
setError('Token 验证失败,请重试');
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
setAdminToken(tokenToVerify);
|
| 46 |
+
setVerified(true);
|
| 47 |
+
setError('');
|
| 48 |
+
} catch (err) {
|
| 49 |
+
console.error('Error verifying token', err);
|
| 50 |
+
setError('无法验证 token,请稍后再试');
|
| 51 |
+
} finally {
|
| 52 |
+
setLoading(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
if (verified) {
|
| 57 |
+
return <>{children}</>;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div className="min-h-screen flex items-center justify-center bg-background text-text-primary p-6">
|
| 62 |
+
<div className="w-full max-w-md bg-surface/80 border border-surface-hover rounded-2xl shadow-2xl p-6 space-y-5">
|
| 63 |
+
<div>
|
| 64 |
+
<p className="text-sm uppercase tracking-wide text-text-secondary">Private Space Access</p>
|
| 65 |
+
<h1 className="text-2xl font-bold mt-1">Enter Admin Token</h1>
|
| 66 |
+
<p className="text-sm text-text-secondary mt-2">
|
| 67 |
+
这个站点是私有的,访问前需要输入管理员 token。
|
| 68 |
+
</p>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div className="space-y-3">
|
| 72 |
+
<label className="block text-sm text-text-secondary">Admin token</label>
|
| 73 |
+
<input
|
| 74 |
+
type="password"
|
| 75 |
+
className="input w-full"
|
| 76 |
+
placeholder="例如:my-secret-token"
|
| 77 |
+
value={tokenInput}
|
| 78 |
+
onChange={(e) => setTokenInput(e.target.value)}
|
| 79 |
+
onKeyDown={(e) => {
|
| 80 |
+
if (e.key === 'Enter') {
|
| 81 |
+
verifyToken(tokenInput);
|
| 82 |
+
}
|
| 83 |
+
}}
|
| 84 |
+
disabled={loading}
|
| 85 |
+
/>
|
| 86 |
+
{error && <p className="text-red-400 text-sm">{error}</p>}
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<button
|
| 90 |
+
className="btn btn-primary w-full"
|
| 91 |
+
onClick={() => verifyToken(tokenInput)}
|
| 92 |
+
disabled={loading}
|
| 93 |
+
>
|
| 94 |
+
{loading ? '验证中...' : '进入我的空间'}
|
| 95 |
+
</button>
|
| 96 |
+
|
| 97 |
+
<p className="text-xs text-text-secondary text-center">
|
| 98 |
+
如需重置或更换 token,请刷新页面重新输入。
|
| 99 |
+
</p>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
);
|
| 103 |
+
}
|
apps/frontend/src/components/CreateClipboardCard.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|
| 5 |
import PasswordModal from './PasswordModal';
|
| 6 |
import { useToast } from './ui/Toast';
|
| 7 |
import { apiUrl } from '@/lib/constants';
|
|
|
|
| 8 |
|
| 9 |
export default function CreateClipboardCard() {
|
| 10 |
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -51,9 +52,9 @@ export default function CreateClipboardCard() {
|
|
| 51 |
|
| 52 |
const response = await fetch(`${apiUrl}/clipboard/create`, {
|
| 53 |
method: 'POST',
|
| 54 |
-
headers: {
|
| 55 |
'Content-Type': 'application/json',
|
| 56 |
-
},
|
| 57 |
body: Object.keys(payload).length ? JSON.stringify(payload) : undefined,
|
| 58 |
});
|
| 59 |
|
|
|
|
| 5 |
import PasswordModal from './PasswordModal';
|
| 6 |
import { useToast } from './ui/Toast';
|
| 7 |
import { apiUrl } from '@/lib/constants';
|
| 8 |
+
import { withAdminTokenHeader } from '@/lib/adminAuth';
|
| 9 |
|
| 10 |
export default function CreateClipboardCard() {
|
| 11 |
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
| 52 |
|
| 53 |
const response = await fetch(`${apiUrl}/clipboard/create`, {
|
| 54 |
method: 'POST',
|
| 55 |
+
headers: withAdminTokenHeader({
|
| 56 |
'Content-Type': 'application/json',
|
| 57 |
+
}),
|
| 58 |
body: Object.keys(payload).length ? JSON.stringify(payload) : undefined,
|
| 59 |
});
|
| 60 |
|
apps/frontend/src/components/JoinClipboardCard.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { useSavedClipboards } from '../lib/hooks/useSavedClipboards';
|
|
| 8 |
import SavedClipboardsButton from './SavedClipboardsButton';
|
| 9 |
import { LogIn, AlertCircle, Loader } from 'lucide-react';
|
| 10 |
import { apiUrl } from '@/lib/constants';
|
|
|
|
| 11 |
|
| 12 |
export default function JoinClipboardCard() {
|
| 13 |
const [roomCode, setRoomCode] = useState('');
|
|
@@ -65,7 +66,11 @@ export default function JoinClipboardCard() {
|
|
| 65 |
|
| 66 |
try {
|
| 67 |
// Check if clipboard exists
|
| 68 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
if (response.data.exists) {
|
| 71 |
// Save to recently visited clipboards
|
|
|
|
| 8 |
import SavedClipboardsButton from './SavedClipboardsButton';
|
| 9 |
import { LogIn, AlertCircle, Loader } from 'lucide-react';
|
| 10 |
import { apiUrl } from '@/lib/constants';
|
| 11 |
+
import { getAdminToken } from '@/lib/adminAuth';
|
| 12 |
|
| 13 |
export default function JoinClipboardCard() {
|
| 14 |
const [roomCode, setRoomCode] = useState('');
|
|
|
|
| 66 |
|
| 67 |
try {
|
| 68 |
// Check if clipboard exists
|
| 69 |
+
const token = getAdminToken();
|
| 70 |
+
const response = await axios.get(`${apiUrl}/clipboard/${roomCode}/exists`, {
|
| 71 |
+
headers: token ? { 'x-admin-token': token } : undefined,
|
| 72 |
+
params: token ? { token } : undefined,
|
| 73 |
+
});
|
| 74 |
|
| 75 |
if (response.data.exists) {
|
| 76 |
// Save to recently visited clipboards
|
apps/frontend/src/components/PasswordModal.tsx
CHANGED
|
@@ -18,6 +18,7 @@ export default function PasswordModal({
|
|
| 18 |
}: PasswordModalProps) {
|
| 19 |
const [password, setPassword] = useState('');
|
| 20 |
const [passwordError, setPasswordError] = useState('');
|
|
|
|
| 21 |
|
| 22 |
const handleSubmit = () => {
|
| 23 |
setPasswordError('');
|
|
@@ -38,6 +39,7 @@ export default function PasswordModal({
|
|
| 38 |
const handleClose = () => {
|
| 39 |
setPassword('');
|
| 40 |
setPasswordError('');
|
|
|
|
| 41 |
onClose();
|
| 42 |
};
|
| 43 |
|
|
@@ -54,33 +56,70 @@ export default function PasswordModal({
|
|
| 54 |
title="Set Password"
|
| 55 |
icon={passwordIcon}
|
| 56 |
>
|
| 57 |
-
<
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
<div>
|
| 63 |
<div className="relative">
|
| 64 |
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
| 65 |
-
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-text-secondary/
|
| 66 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
| 67 |
</svg>
|
| 68 |
</div>
|
| 69 |
<input
|
| 70 |
-
type=
|
| 71 |
value={password}
|
| 72 |
onChange={(e) => {
|
| 73 |
setPassword(e.target.value);
|
| 74 |
if (passwordError) setPasswordError('');
|
| 75 |
}}
|
| 76 |
-
placeholder="
|
| 77 |
-
className="w-full pl-10 pr-
|
| 78 |
autoFocus
|
| 79 |
autoComplete="off"
|
| 80 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</div>
|
|
|
|
| 82 |
{passwordError && (
|
| 83 |
-
<div className="mt-
|
| 84 |
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 85 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 86 |
</svg>
|
|
@@ -93,7 +132,7 @@ export default function PasswordModal({
|
|
| 93 |
<button
|
| 94 |
type="button"
|
| 95 |
onClick={handleClose}
|
| 96 |
-
className="flex-1 py-2 px-4 border border-surface-hover bg-surface hover:bg-surface-hover text-text-primary font-medium rounded-lg transition-
|
| 97 |
>
|
| 98 |
Cancel
|
| 99 |
</button>
|
|
@@ -101,7 +140,7 @@ export default function PasswordModal({
|
|
| 101 |
type="button"
|
| 102 |
onClick={handleSubmit}
|
| 103 |
disabled={isLoading}
|
| 104 |
-
className="flex-1 py-2 px-4 bg-primary hover:
|
| 105 |
>
|
| 106 |
{isLoading ? (
|
| 107 |
<span className="flex items-center justify-center">
|
|
@@ -111,7 +150,14 @@ export default function PasswordModal({
|
|
| 111 |
</svg>
|
| 112 |
Creating...
|
| 113 |
</span>
|
| 114 |
-
) :
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
</button>
|
| 116 |
</div>
|
| 117 |
</div>
|
|
|
|
| 18 |
}: PasswordModalProps) {
|
| 19 |
const [password, setPassword] = useState('');
|
| 20 |
const [passwordError, setPasswordError] = useState('');
|
| 21 |
+
const [showPassword, setShowPassword] = useState(false);
|
| 22 |
|
| 23 |
const handleSubmit = () => {
|
| 24 |
setPasswordError('');
|
|
|
|
| 39 |
const handleClose = () => {
|
| 40 |
setPassword('');
|
| 41 |
setPasswordError('');
|
| 42 |
+
setShowPassword(false);
|
| 43 |
onClose();
|
| 44 |
};
|
| 45 |
|
|
|
|
| 56 |
title="Set Password"
|
| 57 |
icon={passwordIcon}
|
| 58 |
>
|
| 59 |
+
<div className="space-y-5">
|
| 60 |
+
<div className="rounded-lg border border-primary/15 bg-primary/5 px-4 py-3 flex items-start gap-3">
|
| 61 |
+
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
| 62 |
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 63 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
|
| 64 |
+
</svg>
|
| 65 |
+
</div>
|
| 66 |
+
<div>
|
| 67 |
+
<p className="text-text-primary font-medium">Add a passcode for extra privacy</p>
|
| 68 |
+
<p className="text-text-secondary text-sm">Anyone accessing this clipboard will need the code you choose.</p>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
|
| 72 |
+
<div className="space-y-3">
|
|
|
|
| 73 |
<div className="relative">
|
| 74 |
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
| 75 |
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-text-secondary/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 76 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
| 77 |
</svg>
|
| 78 |
</div>
|
| 79 |
<input
|
| 80 |
+
type={showPassword ? 'text' : 'password'}
|
| 81 |
value={password}
|
| 82 |
onChange={(e) => {
|
| 83 |
setPassword(e.target.value);
|
| 84 |
if (passwordError) setPasswordError('');
|
| 85 |
}}
|
| 86 |
+
placeholder="Create a passcode"
|
| 87 |
+
className="w-full pl-10 pr-12 py-3 rounded-lg bg-surface/80 border border-surface-hover focus:border-primary focus:ring-4 focus:ring-primary/20 focus:outline-none transition-colors duration-300 ease-in-out text-text-primary placeholder-text-secondary/50 font-mono shadow-inner"
|
| 88 |
autoFocus
|
| 89 |
autoComplete="off"
|
| 90 |
/>
|
| 91 |
+
<button
|
| 92 |
+
type="button"
|
| 93 |
+
onClick={() => setShowPassword((prev) => !prev)}
|
| 94 |
+
className="absolute inset-y-0 right-0 px-3 flex items-center text-text-secondary/70 hover:text-text-primary transition-colors"
|
| 95 |
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
| 96 |
+
>
|
| 97 |
+
{showPassword ? (
|
| 98 |
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 99 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-5 0-9.27-3.11-11-7 1.05-2.38 2.9-4.36 5.1-5.66m4.02-1.09A9.98 9.98 0 0112 5c5 0 9.27 3.11 11 7a11.72 11.72 0 01-1.67 2.63M15 12a3 3 0 00-3-3m0 0a3 3 0 013 3m-3-3c-.64 0-1.26.19-1.77.52m0 0L3 21m7.23-8.48a3 3 0 104.24 4.24" />
|
| 100 |
+
</svg>
|
| 101 |
+
) : (
|
| 102 |
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 103 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 104 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
| 105 |
+
</svg>
|
| 106 |
+
)}
|
| 107 |
+
</button>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div className="grid grid-cols-2 gap-3 text-xs text-text-secondary">
|
| 111 |
+
<div className="flex items-center gap-2 rounded-md bg-surface/60 border border-surface-hover px-3 py-2">
|
| 112 |
+
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary">4+</span>
|
| 113 |
+
<span>At least 4 characters</span>
|
| 114 |
+
</div>
|
| 115 |
+
<div className="flex items-center gap-2 rounded-md bg-surface/60 border border-surface-hover px-3 py-2">
|
| 116 |
+
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary">✦</span>
|
| 117 |
+
<span>Mix letters & numbers</span>
|
| 118 |
+
</div>
|
| 119 |
</div>
|
| 120 |
+
|
| 121 |
{passwordError && (
|
| 122 |
+
<div className="mt-1 text-error text-sm flex items-center">
|
| 123 |
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 124 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 125 |
</svg>
|
|
|
|
| 132 |
<button
|
| 133 |
type="button"
|
| 134 |
onClick={handleClose}
|
| 135 |
+
className="flex-1 py-2.5 px-4 border border-surface-hover bg-surface/70 hover:bg-surface-hover text-text-primary font-medium rounded-lg transition-all duration-200 hover:-translate-y-0.5"
|
| 136 |
>
|
| 137 |
Cancel
|
| 138 |
</button>
|
|
|
|
| 140 |
type="button"
|
| 141 |
onClick={handleSubmit}
|
| 142 |
disabled={isLoading}
|
| 143 |
+
className="flex-1 py-2.5 px-4 bg-gradient-to-r from-primary to-primary-dark hover:brightness-110 text-white font-medium rounded-lg flex items-center justify-center transition-all duration-200 shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 144 |
>
|
| 145 |
{isLoading ? (
|
| 146 |
<span className="flex items-center justify-center">
|
|
|
|
| 150 |
</svg>
|
| 151 |
Creating...
|
| 152 |
</span>
|
| 153 |
+
) : (
|
| 154 |
+
<span className="flex items-center gap-2">
|
| 155 |
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 156 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
| 157 |
+
</svg>
|
| 158 |
+
Create & Lock
|
| 159 |
+
</span>
|
| 160 |
+
)}
|
| 161 |
</button>
|
| 162 |
</div>
|
| 163 |
</div>
|
apps/frontend/src/lib/adminAuth.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
|
| 2 |
+
|
| 3 |
+
export const getAdminToken = (): string | null => {
|
| 4 |
+
if (typeof window === 'undefined') return null;
|
| 5 |
+
return localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export const setAdminToken = (token: string) => {
|
| 9 |
+
if (typeof window === 'undefined') return;
|
| 10 |
+
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token);
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export const clearAdminToken = () => {
|
| 14 |
+
if (typeof window === 'undefined') return;
|
| 15 |
+
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export const withAdminTokenHeader = (headers: HeadersInit = {}): HeadersInit => {
|
| 19 |
+
const token = getAdminToken();
|
| 20 |
+
if (!token) return headers;
|
| 21 |
+
|
| 22 |
+
return {
|
| 23 |
+
...headers,
|
| 24 |
+
'x-admin-token': token,
|
| 25 |
+
};
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export const appendTokenToUrl = (url: string): string => {
|
| 29 |
+
const token = getAdminToken();
|
| 30 |
+
if (!token) return url;
|
| 31 |
+
|
| 32 |
+
const [base, search] = url.split('?');
|
| 33 |
+
const params = new URLSearchParams(search || '');
|
| 34 |
+
params.set('token', token);
|
| 35 |
+
return `${base}?${params.toString()}`;
|
| 36 |
+
};
|
apps/frontend/src/lib/hooks/useSavedClipboards.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import { apiUrl } from '../constants';
|
|
|
|
| 5 |
|
| 6 |
interface SavedClipboard {
|
| 7 |
roomCode: string;
|
|
@@ -66,9 +67,14 @@ export function useSavedClipboards() {
|
|
| 66 |
if (lastChecked && (now.getTime() - lastChecked.getTime() < ONE_HOUR)) {
|
| 67 |
continue;
|
| 68 |
}
|
| 69 |
-
|
| 70 |
try {
|
| 71 |
-
const response = await fetch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
if (response.ok) {
|
| 74 |
const data = await response.json();
|
|
|
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import { apiUrl } from '../constants';
|
| 5 |
+
import { appendTokenToUrl, withAdminTokenHeader } from '../adminAuth';
|
| 6 |
|
| 7 |
interface SavedClipboard {
|
| 8 |
roomCode: string;
|
|
|
|
| 67 |
if (lastChecked && (now.getTime() - lastChecked.getTime() < ONE_HOUR)) {
|
| 68 |
continue;
|
| 69 |
}
|
| 70 |
+
|
| 71 |
try {
|
| 72 |
+
const response = await fetch(
|
| 73 |
+
appendTokenToUrl(`${apiUrl}/clipboard/${clipboard.roomCode}/exists`),
|
| 74 |
+
{
|
| 75 |
+
headers: withAdminTokenHeader(),
|
| 76 |
+
},
|
| 77 |
+
);
|
| 78 |
|
| 79 |
if (response.ok) {
|
| 80 |
const data = await response.json();
|
apps/frontend/src/lib/hooks/useSocketManager.ts
CHANGED
|
@@ -3,6 +3,7 @@ import io, { Socket } from 'socket.io-client';
|
|
| 3 |
import { ClipboardEntryType } from '@/app/[roomCode]/components/ClipboardEntry';
|
| 4 |
import { FileEntryType } from '@/app/[roomCode]/components/FileEntry';
|
| 5 |
import { apiUrl, socketUrl } from '../constants';
|
|
|
|
| 6 |
|
| 7 |
interface UseSocketManagerOptions {
|
| 8 |
roomCode: string;
|
|
@@ -71,8 +72,9 @@ export function useSocketManager({ roomCode, clientId }: UseSocketManagerOptions
|
|
| 71 |
clientId,
|
| 72 |
});
|
| 73 |
|
| 74 |
-
fetch(`${apiUrl}/clipboard/${roomCode}/files/${fileId}`, {
|
| 75 |
method: 'DELETE',
|
|
|
|
| 76 |
}).catch(err => {
|
| 77 |
console.error('Error deleting file:', err);
|
| 78 |
});
|
|
@@ -93,9 +95,18 @@ export function useSocketManager({ roomCode, clientId }: UseSocketManagerOptions
|
|
| 93 |
return;
|
| 94 |
}
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
// Connect directly to the Socket.IO server using our dedicated WebSocket port
|
| 97 |
socketRef.current = io(socketUrl, {
|
| 98 |
-
query: { roomCode },
|
|
|
|
| 99 |
transports: ['websocket', 'polling'],
|
| 100 |
reconnectionAttempts: 5,
|
| 101 |
reconnectionDelay: 1000,
|
|
|
|
| 3 |
import { ClipboardEntryType } from '@/app/[roomCode]/components/ClipboardEntry';
|
| 4 |
import { FileEntryType } from '@/app/[roomCode]/components/FileEntry';
|
| 5 |
import { apiUrl, socketUrl } from '../constants';
|
| 6 |
+
import { appendTokenToUrl, getAdminToken, withAdminTokenHeader } from '../adminAuth';
|
| 7 |
|
| 8 |
interface UseSocketManagerOptions {
|
| 9 |
roomCode: string;
|
|
|
|
| 72 |
clientId,
|
| 73 |
});
|
| 74 |
|
| 75 |
+
fetch(appendTokenToUrl(`${apiUrl}/clipboard/${roomCode}/files/${fileId}`), {
|
| 76 |
method: 'DELETE',
|
| 77 |
+
headers: withAdminTokenHeader(),
|
| 78 |
}).catch(err => {
|
| 79 |
console.error('Error deleting file:', err);
|
| 80 |
});
|
|
|
|
| 95 |
return;
|
| 96 |
}
|
| 97 |
|
| 98 |
+
const adminToken = getAdminToken();
|
| 99 |
+
|
| 100 |
+
if (!adminToken) {
|
| 101 |
+
setError('Admin token missing. Please refresh and enter your token.');
|
| 102 |
+
setIsLoading(false);
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
// Connect directly to the Socket.IO server using our dedicated WebSocket port
|
| 107 |
socketRef.current = io(socketUrl, {
|
| 108 |
+
query: { roomCode, token: adminToken },
|
| 109 |
+
extraHeaders: { 'x-admin-token': adminToken },
|
| 110 |
transports: ['websocket', 'polling'],
|
| 111 |
reconnectionAttempts: 5,
|
| 112 |
reconnectionDelay: 1000,
|