Poki01 commited on
Commit
3bbb98d
·
1 Parent(s): 56181a0

Require admin token for private access

Browse files
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', `${apiUrl}/clipboard/${roomCode}/files`, true);
 
 
 
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(`${apiUrl}/clipboard/${roomCode}/exists`);
 
 
 
 
 
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
- <div className="relative">
54
- {/* Decorative elements */}
55
- <div className="fixed inset-0 z-[-1] overflow-hidden">
56
- <div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-gradient-radial from-primary/5 to-transparent opacity-30 blur-3xl" />
57
- <div className="absolute -bottom-[10%] -right-[10%] w-[40%] h-[40%] bg-gradient-radial from-secondary/5 to-transparent opacity-30 blur-3xl" />
58
- </div>
 
59
 
60
- <main className="flex flex-col min-h-screen">
61
- {children}
62
- </main>
63
- </div>
 
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 response = await axios.get(`${apiUrl}/clipboard/${roomCode}/exists`);
 
 
 
 
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
- <p className="text-text-secondary mb-4 text-center">
58
- Create a password-protected clipboard.
59
- </p>
 
 
 
 
 
 
 
 
 
60
 
61
- <div className="space-y-4">
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/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
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="text"
71
  value={password}
72
  onChange={(e) => {
73
  setPassword(e.target.value);
74
  if (passwordError) setPasswordError('');
75
  }}
76
- placeholder="Enter password"
77
- className="w-full pl-10 pr-4 py-3 rounded-lg bg-surface/80 border-2 border-surface-hover focus:border-primary focus:outline-none focus:ring-0 transition-colors duration-300 ease-in-out text-text-primary placeholder-text-secondary/50 font-mono"
78
  autoFocus
79
  autoComplete="off"
80
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  </div>
 
82
  {passwordError && (
83
- <div className="mt-2 text-error text-sm flex items-center">
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-colors duration-200"
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:bg-primary/90 text-white font-medium rounded-lg flex items-center justify-center transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
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
- ) : 'Create'}
 
 
 
 
 
 
 
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(`${apiUrl}/clipboard/${clipboard.roomCode}/exists`);
 
 
 
 
 
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,