feat: Implement Share Modal for document sharing functionality
- Added ShareModal component to manage user and link sharing for documents. - Created AuthContext to handle user authentication state and token management. - Updated useYjsDocument hook to support sharing via tokens. - Enhanced Yjs document creation to include user information and authentication tokens. - Introduced AuthCallback page to handle authentication redirects and token processing. - Modified EditorPage and KanbanPage to include share functionality. - Created LoginPage with Google and GitHub authentication options. - Added styles for LoginPage. - Defined types for authentication and sharing in respective TypeScript files.
This commit is contained in:
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-collaboration": "^2.27.1",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.26.2",
|
||||
"@tiptap/pm": "^2.27.1",
|
||||
@@ -317,6 +320,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
@@ -3792,6 +3848,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-collaboration": "^2.27.1",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.26.2",
|
||||
"@tiptap/pm": "^2.27.1",
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import AuthCallback from "./pages/AuthCallback";
|
||||
import EditorPage from "./pages/EditorPage.tsx";
|
||||
import Home from "./pages/Home.tsx";
|
||||
import KanbanPage from "./pages/KanbanPage.tsx";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/editor/:id" element={<EditorPage />} />
|
||||
<Route path="/kanban/:id" element={<KanbanPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Home />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/editor/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<EditorPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/kanban/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<KanbanPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
24
frontend/src/api/auth.ts
Normal file
24
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { User } from '../types/auth';
|
||||
import { API_BASE_URL, authFetch } from './client';
|
||||
|
||||
export const authApi = {
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await authFetch(`${API_BASE_URL}/auth/me`);
|
||||
console.log("current user is " + response)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get current user');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
const response = await authFetch(`${API_BASE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to logout');
|
||||
}
|
||||
},
|
||||
};
|
||||
31
frontend/src/api/client.ts
Normal file
31
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api";
|
||||
|
||||
export async function authFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
// Add Authorization header if token exists
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle 401: Token expired or invalid
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export { API_BASE_URL };
|
||||
@@ -1,4 +1,4 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api";
|
||||
import { authFetch, API_BASE_URL } from './client';
|
||||
|
||||
export type DocumentType = {
|
||||
id: string;
|
||||
@@ -16,23 +16,22 @@ export type CreateDocumentRequest = {
|
||||
export const documentsApi = {
|
||||
// List all documents
|
||||
list: async (): Promise<{ documents: DocumentType[]; total: number }> => {
|
||||
const response = await fetch(`${API_BASE_URL}/documents`);
|
||||
const response = await authFetch(`${API_BASE_URL}/documents`);
|
||||
if (!response.ok) throw new Error("Failed to fetch documents");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get a single document
|
||||
get: async (id: string): Promise<DocumentType> => {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/${id}`);
|
||||
const response = await authFetch(`${API_BASE_URL}/documents/${id}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch document");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Create a new document
|
||||
create: async (data: CreateDocumentRequest): Promise<DocumentType> => {
|
||||
const response = await fetch(`${API_BASE_URL}/documents`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/documents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to create document");
|
||||
@@ -41,7 +40,7 @@ export const documentsApi = {
|
||||
|
||||
// Delete a document
|
||||
delete: async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/${id}`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/documents/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to delete document");
|
||||
@@ -49,7 +48,7 @@ export const documentsApi = {
|
||||
|
||||
// Get document Yjs state
|
||||
getState: async (id: string): Promise<Uint8Array> => {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/${id}/state`);
|
||||
const response = await authFetch(`${API_BASE_URL}/documents/${id}/state`);
|
||||
if (!response.ok) throw new Error("Failed to fetch document state");
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return new Uint8Array(arrayBuffer);
|
||||
@@ -61,7 +60,7 @@ export const documentsApi = {
|
||||
const buffer = new ArrayBuffer(state.byteLength);
|
||||
new Uint8Array(buffer).set(state);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/documents/${id}/state`, {
|
||||
const response = await authFetch(`${API_BASE_URL}/documents/${id}/state`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: buffer,
|
||||
|
||||
100
frontend/src/api/share.ts
Normal file
100
frontend/src/api/share.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { authFetch, API_BASE_URL } from './client';
|
||||
import type {
|
||||
DocumentShareWithUser,
|
||||
CreateShareRequest,
|
||||
ShareLink,
|
||||
} from '../types/share';
|
||||
|
||||
export const shareApi = {
|
||||
// Create a share with a specific user
|
||||
createShare: async (
|
||||
documentId: string,
|
||||
request: CreateShareRequest
|
||||
): Promise<DocumentShareWithUser> => {
|
||||
const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create share');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// List all shares for a document
|
||||
listShares: async (documentId: string): Promise<DocumentShareWithUser[]> => {
|
||||
const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/shares`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to list shares');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.shares || [];
|
||||
},
|
||||
|
||||
// Delete a share
|
||||
deleteShare: async (documentId: string, userId: string): Promise<void> => {
|
||||
const response = await authFetch(
|
||||
`${API_BASE_URL}/documents/${documentId}/shares/${userId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete share');
|
||||
}
|
||||
},
|
||||
|
||||
// Create a public share link
|
||||
createShareLink: async (
|
||||
documentId: string,
|
||||
permission: 'view' | 'edit'
|
||||
): Promise<ShareLink> => {
|
||||
const response = await authFetch(
|
||||
`${API_BASE_URL}/documents/${documentId}/share-link`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ permission }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create share link');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get the current share link
|
||||
getShareLink: async (documentId: string): Promise<ShareLink | null> => {
|
||||
const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/share-link`);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get share link');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Revoke the share link
|
||||
revokeShareLink: async (documentId: string): Promise<void> => {
|
||||
const response = await authFetch(
|
||||
`${API_BASE_URL}/documents/${documentId}/share-link`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to revoke share link');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { Task } from "./KanbanBoard.tsx";
|
||||
|
||||
interface CardProps {
|
||||
@@ -5,8 +7,30 @@ interface CardProps {
|
||||
}
|
||||
|
||||
const Card = ({ task }: CardProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: task.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kanban-card">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="kanban-card"
|
||||
>
|
||||
<h4>{task.title}</h4>
|
||||
{task.description && <p>{task.description}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import Card from "./Card.tsx";
|
||||
import type { KanbanColumn, Task } from "./KanbanBoard.tsx";
|
||||
|
||||
@@ -12,6 +14,10 @@ const Column = ({ column, onAddTask }: ColumnProps) => {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: column.id,
|
||||
});
|
||||
|
||||
const handleAddTask = () => {
|
||||
if (newTaskTitle.trim()) {
|
||||
onAddTask({
|
||||
@@ -25,12 +31,20 @@ const Column = ({ column, onAddTask }: ColumnProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kanban-column">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`kanban-column ${isOver ? 'column-drag-over' : ''}`}
|
||||
>
|
||||
<h3 className="column-title">{column.title}</h3>
|
||||
<div className="column-content">
|
||||
{column.tasks.map((task) => (
|
||||
<Card key={task.id} task={task} />
|
||||
))}
|
||||
<SortableContext
|
||||
items={column.tasks.map(t => t.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{column.tasks.map((task) => (
|
||||
<Card key={task.id} task={task} />
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{isAdding ? (
|
||||
<div className="add-task-form">
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import type { YjsProviders } from "../../lib/yjs";
|
||||
import Column from "./Column.tsx";
|
||||
|
||||
@@ -21,6 +28,14 @@ export interface KanbanColumn {
|
||||
const KanbanBoard = ({ providers }: KanbanBoardProps) => {
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>([]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // Prevent accidental drags
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const yarray = providers.ydoc.getArray<any>("kanban-columns");
|
||||
|
||||
@@ -93,19 +108,39 @@ const KanbanBoard = ({ providers }: KanbanBoardProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const targetColumnId = over.id as string;
|
||||
|
||||
// Find which column the task is currently in
|
||||
const fromColumn = columns.find(col =>
|
||||
col.tasks.some(task => task.id === taskId)
|
||||
);
|
||||
|
||||
if (fromColumn && fromColumn.id !== targetColumnId) {
|
||||
moveTask(fromColumn.id, targetColumnId, taskId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kanban-board">
|
||||
{columns.map((column) => (
|
||||
<Column
|
||||
key={column.id}
|
||||
column={column}
|
||||
onAddTask={(task) => addTask(column.id, task)}
|
||||
onMoveTask={(taskId, toColumnId) =>
|
||||
moveTask(column.id, toColumnId, taskId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<div className="kanban-board">
|
||||
{columns.map((column) => (
|
||||
<Column
|
||||
key={column.id}
|
||||
column={column}
|
||||
onAddTask={(task) => addTask(column.id, task)}
|
||||
onMoveTask={(taskId, toColumnId) =>
|
||||
moveTask(column.id, toColumnId, taskId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
65
frontend/src/components/Navbar.css
Normal file
65
frontend/src/components/Navbar.css
Normal file
@@ -0,0 +1,65 @@
|
||||
.navbar {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 12px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navbar-brand a {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar-brand a:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.navbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 6px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #718096;
|
||||
background: #f7fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: #edf2f7;
|
||||
color: #2d3748;
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
34
frontend/src/components/Navbar.tsx
Normal file
34
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Navbar.css';
|
||||
|
||||
function Navbar() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="navbar-content">
|
||||
<div className="navbar-brand">
|
||||
<a href="/">Realtime Collab</a>
|
||||
</div>
|
||||
|
||||
<div className="navbar-user">
|
||||
{user.avatar_url && (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.name}
|
||||
className="user-avatar"
|
||||
/>
|
||||
)}
|
||||
<span className="user-name">{user.name}</span>
|
||||
<button onClick={logout} className="logout-button">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Awareness } from "y-protocols/awareness";
|
||||
|
||||
interface UserListProps {
|
||||
awareness: any;
|
||||
awareness: Awareness;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -26,6 +27,7 @@ const UserList = ({ awareness }: UserListProps) => {
|
||||
color: state.user.color,
|
||||
});
|
||||
}
|
||||
console.log("one of the user name is" + state.user.name);
|
||||
});
|
||||
|
||||
setUsers(userList);
|
||||
|
||||
33
frontend/src/components/ProtectedRoute.tsx
Normal file
33
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
}}>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to={`/login?redirect=${location.pathname}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
365
frontend/src/components/Share/ShareModal.css
Normal file
365
frontend/src/components/Share/ShareModal.css
Normal file
@@ -0,0 +1,365 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #667eea;
|
||||
border-bottom-color: #667eea;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #fff5f5;
|
||||
color: #c53030;
|
||||
border: 1px solid #feb2b2;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #f0fff4;
|
||||
color: #22543d;
|
||||
border: 1px solid #9ae6b4;
|
||||
}
|
||||
|
||||
.share-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.share-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.share-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.share-select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.share-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.share-button {
|
||||
padding: 10px 20px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.share-button:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.share-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.shares-list h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.share-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.share-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.share-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.share-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.share-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.share-email {
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.permission-badge {
|
||||
padding: 4px 12px;
|
||||
background: #edf2f7;
|
||||
color: #4a5568;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.remove-button:hover:not(:disabled) {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.remove-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.link-creation {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-creation p {
|
||||
color: #4a5568;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.link-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.link-display > p {
|
||||
color: #4a5568;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
padding: 10px 20px;
|
||||
background: #48bb78;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #38a169;
|
||||
}
|
||||
|
||||
.link-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.link-date {
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.revoke-button {
|
||||
padding: 10px 20px;
|
||||
background: white;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.revoke-button:hover:not(:disabled) {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.revoke-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
292
frontend/src/components/Share/ShareModal.tsx
Normal file
292
frontend/src/components/Share/ShareModal.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { shareApi } from '../../api/share';
|
||||
import type { DocumentShareWithUser, ShareLink } from '../../types/share';
|
||||
import './ShareModal.css';
|
||||
|
||||
interface ShareModalProps {
|
||||
documentId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ShareModal({ documentId, onClose }: ShareModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'link'>('users');
|
||||
const [shares, setShares] = useState<DocumentShareWithUser[]>([]);
|
||||
const [shareLink, setShareLink] = useState<ShareLink | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Form state for user sharing
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
const [permission, setPermission] = useState<'view' | 'edit'>('view');
|
||||
|
||||
// Form state for link sharing
|
||||
const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('view');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Load shares on mount
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
loadShareLink();
|
||||
}, [documentId]);
|
||||
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
const data = await shareApi.listShares(documentId);
|
||||
setShares(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load shares:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadShareLink = async () => {
|
||||
try {
|
||||
const link = await shareApi.getShareLink(documentId);
|
||||
setShareLink(link);
|
||||
if (link) {
|
||||
setLinkPermission(link.permission);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load share link:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!userEmail.trim()) {
|
||||
setError('Please enter an email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
await shareApi.createShare(documentId, {
|
||||
user_email: userEmail,
|
||||
permission,
|
||||
});
|
||||
|
||||
setSuccess('User added successfully');
|
||||
setUserEmail('');
|
||||
await loadShares();
|
||||
} catch (err) {
|
||||
setError('Failed to add user. Make sure the email is registered.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (userId: string) => {
|
||||
if (!confirm('Remove access for this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await shareApi.deleteShare(documentId, userId);
|
||||
setSuccess('User removed successfully');
|
||||
await loadShares();
|
||||
} catch (err) {
|
||||
setError('Failed to remove user');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateLink = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const link = await shareApi.createShareLink(documentId, linkPermission);
|
||||
setShareLink(link);
|
||||
setSuccess('Share link created');
|
||||
} catch (err) {
|
||||
setError('Failed to create share link');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeLink = async () => {
|
||||
if (!confirm('Revoke this share link? Anyone with the link will lose access.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await shareApi.revokeShareLink(documentId);
|
||||
setShareLink(null);
|
||||
setSuccess('Share link revoked');
|
||||
} catch (err) {
|
||||
setError('Failed to revoke share link');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (!shareLink) return;
|
||||
|
||||
const url = `${window.location.origin}/editor/${documentId}?share=${shareLink.token}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Share Document</h2>
|
||||
<button className="close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={`tab ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
Share with Users
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${activeTab === 'link' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('link')}
|
||||
>
|
||||
Public Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="message error">{error}</div>}
|
||||
{success && <div className="message success">{success}</div>}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="tab-content">
|
||||
<form onSubmit={handleAddUser} className="share-form">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={userEmail}
|
||||
onChange={(e) => setUserEmail(e.target.value)}
|
||||
className="share-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<select
|
||||
value={permission}
|
||||
onChange={(e) => setPermission(e.target.value as 'view' | 'edit')}
|
||||
className="share-select"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="view">Can view</option>
|
||||
<option value="edit">Can edit</option>
|
||||
</select>
|
||||
<button type="submit" className="share-button" disabled={loading}>
|
||||
{loading ? 'Adding...' : 'Add User'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="shares-list">
|
||||
<h3>People with access</h3>
|
||||
{shares.length === 0 ? (
|
||||
<p className="empty-state">No users have been given access yet.</p>
|
||||
) : (
|
||||
shares.map((share) => (
|
||||
<div key={share.id} className="share-item">
|
||||
<div className="share-user">
|
||||
{share.user.avatar_url && (
|
||||
<img
|
||||
src={share.user.avatar_url}
|
||||
alt={share.user.name}
|
||||
className="share-avatar"
|
||||
/>
|
||||
)}
|
||||
<div className="share-info">
|
||||
<div className="share-name">{share.user.name}</div>
|
||||
<div className="share-email">{share.user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="share-actions">
|
||||
<span className="permission-badge">{share.permission}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveUser(share.user_id)}
|
||||
className="remove-button"
|
||||
disabled={loading}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'link' && (
|
||||
<div className="tab-content">
|
||||
{!shareLink ? (
|
||||
<div className="link-creation">
|
||||
<p>Create a public link that anyone can use to access this document.</p>
|
||||
<div className="link-form">
|
||||
<select
|
||||
value={linkPermission}
|
||||
onChange={(e) => setLinkPermission(e.target.value as 'view' | 'edit')}
|
||||
className="share-select"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="view">Can view</option>
|
||||
<option value="edit">Can edit</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleGenerateLink}
|
||||
className="share-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Generate Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="link-display">
|
||||
<p>Anyone with this link can {shareLink.permission} this document.</p>
|
||||
<div className="link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={`${window.location.origin}/editor/${documentId}?share=${shareLink.token}`}
|
||||
readOnly
|
||||
className="link-input"
|
||||
/>
|
||||
<button onClick={handleCopyLink} className="copy-button">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="link-meta">
|
||||
<span className="permission-badge">{shareLink.permission}</span>
|
||||
<span className="link-date">
|
||||
Created {new Date(shareLink.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRevokeLink}
|
||||
className="revoke-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Revoking...' : 'Revoke Link'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareModal;
|
||||
109
frontend/src/contexts/AuthContext.tsx
Normal file
109
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import type { User, AuthContextType } from '../types/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize auth state on mount
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const storedToken = localStorage.getItem('auth_token');
|
||||
|
||||
if (!storedToken) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setToken(storedToken);
|
||||
const currentUser = await authApi.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to validate token:', err);
|
||||
localStorage.removeItem('auth_token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setError('Session expired');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (newToken: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
localStorage.setItem('auth_token', newToken);
|
||||
setToken(newToken);
|
||||
|
||||
const currentUser = await authApi.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
localStorage.removeItem('auth_token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setError('Login failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setError(null);
|
||||
|
||||
// Call backend logout endpoint (fire and forget)
|
||||
authApi.logout().catch(console.error);
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const currentUser = await authApi.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh user:', err);
|
||||
setError('Failed to refresh user');
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
createYjsDocument,
|
||||
destroyYjsDocument,
|
||||
getRandomColor,
|
||||
getRandomName,
|
||||
getColorFromUserId,
|
||||
type YjsProviders,
|
||||
} from "../lib/yjs";
|
||||
import { useAutoSave } from "./useAutoSave";
|
||||
|
||||
export const useYjsDocument = (documentId: string) => {
|
||||
export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
||||
const { user, token } = useAuth();
|
||||
const [providers, setProviders] = useState<YjsProviders | null>(null);
|
||||
const [synced, setSynced] = useState(false);
|
||||
|
||||
@@ -16,11 +17,36 @@ export const useYjsDocument = (documentId: string) => {
|
||||
useAutoSave(documentId, providers?.ydoc || null);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for auth (unless we have a share token for public access)
|
||||
if (!shareToken && (!user || !token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let currentProviders: YjsProviders | null = null;
|
||||
|
||||
const initializeDocument = async () => {
|
||||
const yjsProviders = await createYjsDocument(documentId);
|
||||
// For share token access, use placeholder user info
|
||||
const authUser = user || {
|
||||
id: "anonymous",
|
||||
name: "Anonymous User",
|
||||
avatar_url: undefined,
|
||||
};
|
||||
const realUser = user?.user ? user.user : user || {};
|
||||
const currentName = realUser.name || realUser.email || "Anonymous";
|
||||
const currentId = realUser.id;
|
||||
const currentAvatar = realUser.avatar_url || realUser.avatar;
|
||||
console.log("✅ [Fixed] User Name is:", currentName);
|
||||
console.log("🔍 [Debug] Initializing Awareness with User:", authUser); // <--- 添加这行
|
||||
const authToken = token || "";
|
||||
console.log("authToken is " + token);
|
||||
|
||||
const yjsProviders = await createYjsDocument(
|
||||
documentId,
|
||||
{ id: currentId, name: currentName, avatar_url: currentAvatar },
|
||||
authToken,
|
||||
shareToken
|
||||
);
|
||||
currentProviders = yjsProviders;
|
||||
|
||||
if (!mounted) {
|
||||
@@ -28,12 +54,16 @@ export const useYjsDocument = (documentId: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set user info for awareness
|
||||
const userName = getRandomName();
|
||||
const userColor = getRandomColor();
|
||||
console.log(
|
||||
"🔍 [Debug] Full authUser object:",
|
||||
JSON.stringify(authUser, null, 2)
|
||||
);
|
||||
// Set user info for awareness with authenticated user data
|
||||
yjsProviders.awareness.setLocalStateField("user", {
|
||||
name: userName,
|
||||
color: userColor,
|
||||
id: currentId,
|
||||
name: currentName,
|
||||
color: getColorFromUserId(currentId),
|
||||
avatar: currentAvatar,
|
||||
});
|
||||
|
||||
// NEW: Add awareness event logging
|
||||
@@ -47,7 +77,6 @@ export const useYjsDocument = (documentId: string) => {
|
||||
removed: number[];
|
||||
}) => {
|
||||
const states = yjsProviders.awareness.getStates();
|
||||
|
||||
added.forEach((clientId) => {
|
||||
const state = states.get(clientId);
|
||||
const user = state?.user;
|
||||
@@ -95,8 +124,8 @@ export const useYjsDocument = (documentId: string) => {
|
||||
);
|
||||
|
||||
// Log local user info
|
||||
console.log(`[Awareness] Local user initialized: ${userName}`, {
|
||||
color: userColor,
|
||||
console.log(`[Awareness] Local user initialized: ${authUser.name}`, {
|
||||
color: getColorFromUserId(authUser.id),
|
||||
clientId: yjsProviders.awareness.clientID,
|
||||
});
|
||||
|
||||
@@ -114,7 +143,7 @@ export const useYjsDocument = (documentId: string) => {
|
||||
destroyYjsDocument(currentProviders);
|
||||
}
|
||||
};
|
||||
}, [documentId]);
|
||||
}, [documentId, user, token, shareToken]);
|
||||
|
||||
|
||||
return { providers, synced };
|
||||
|
||||
@@ -13,7 +13,18 @@ export interface YjsProviders {
|
||||
awareness: Awareness;
|
||||
}
|
||||
|
||||
export const createYjsDocument = async (documentId: string): Promise<YjsProviders> => {
|
||||
export interface YjsUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export const createYjsDocument = async (
|
||||
documentId: string,
|
||||
user: YjsUser,
|
||||
token: string,
|
||||
shareToken?: string
|
||||
): Promise<YjsProviders> => {
|
||||
// Create Yjs document
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
@@ -31,8 +42,16 @@ export const createYjsDocument = async (documentId: string): Promise<YjsProvider
|
||||
// IndexedDB persistence (offline support)
|
||||
const indexeddbProvider = new IndexeddbPersistence(documentId, ydoc);
|
||||
|
||||
// WebSocket provider (real-time sync)
|
||||
const websocketProvider = new WebsocketProvider(WS_URL, documentId, ydoc);
|
||||
// WebSocket provider (real-time sync) with auth token
|
||||
const wsParams: { [key: string]: string } = shareToken
|
||||
? { share: shareToken }
|
||||
: { token: token };
|
||||
const websocketProvider = new WebsocketProvider(
|
||||
WS_URL,
|
||||
documentId,
|
||||
ydoc,
|
||||
{ params: wsParams }
|
||||
);
|
||||
|
||||
// Awareness for cursors and presence
|
||||
const awareness = websocketProvider.awareness;
|
||||
@@ -51,8 +70,15 @@ export const destroyYjsDocument = (providers: YjsProviders) => {
|
||||
providers.ydoc.destroy();
|
||||
};
|
||||
|
||||
// Random color generator for users
|
||||
export const getRandomColor = () => {
|
||||
// Deterministic color generator based on user ID
|
||||
export const getColorFromUserId = (userId: string | undefined): string => {
|
||||
// Default color if no userId
|
||||
if (!userId) {
|
||||
return "#718096"; // Gray for anonymous/undefined users
|
||||
}
|
||||
|
||||
// Hash user ID to consistent color index
|
||||
const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
const colors = [
|
||||
"#FF6B6B",
|
||||
"#4ECDC4",
|
||||
@@ -63,14 +89,5 @@ export const getRandomColor = () => {
|
||||
"#BB8FCE",
|
||||
"#85C1E2",
|
||||
];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
|
||||
// Random name generator
|
||||
export const getRandomName = () => {
|
||||
const adjectives = ["Happy", "Clever", "Brave", "Swift", "Kind"];
|
||||
const animals = ["Panda", "Fox", "Wolf", "Bear", "Eagle"];
|
||||
return `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${
|
||||
animals[Math.floor(Math.random() * animals.length)]
|
||||
}`;
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
68
frontend/src/pages/AuthCallback.tsx
Normal file
68
frontend/src/pages/AuthCallback.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
function AuthCallback() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const token = searchParams.get('token');
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
|
||||
if (!token) {
|
||||
setError('No authentication token received');
|
||||
setTimeout(() => navigate('/login'), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(token);
|
||||
navigate(redirect);
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError('Authentication failed. Please try again.');
|
||||
setTimeout(() => navigate('/login'), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, login, navigate]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{error ? (
|
||||
<>
|
||||
<h2 style={{ color: '#e53e3e', marginBottom: '8px' }}>Error</h2>
|
||||
<p style={{ color: '#718096' }}>{error}</p>
|
||||
<p style={{ color: '#718096', fontSize: '14px', marginTop: '16px' }}>
|
||||
Redirecting to login...
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 style={{ color: '#1a202c', marginBottom: '8px' }}>Logging you in...</h2>
|
||||
<p style={{ color: '#718096' }}>Please wait while we complete the authentication.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthCallback;
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import Editor from "../components/Editor/Editor.tsx";
|
||||
import UserList from "../components/Presence/UserList.tsx";
|
||||
import ShareModal from "../components/Share/ShareModal.tsx";
|
||||
import Navbar from "../components/Navbar.tsx";
|
||||
import { useYjsDocument } from "../hooks/useYjsDocument.ts";
|
||||
|
||||
const EditorPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { providers, synced } = useYjsDocument(id!);
|
||||
const shareToken = searchParams.get('share') || undefined;
|
||||
const { providers, synced } = useYjsDocument(id!, shareToken);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
if (!providers) {
|
||||
return <div className="loading">Connecting...</div>;
|
||||
@@ -14,10 +20,18 @@ const EditorPage = () => {
|
||||
|
||||
return (
|
||||
<div className="editor-page">
|
||||
<Navbar />
|
||||
<div className="page-header">
|
||||
<button onClick={() => navigate("/")}>← Back to Home</button>
|
||||
<div className="sync-status">
|
||||
{synced ? "✓ Synced" : "⟳ Syncing..."}
|
||||
<div className="header-actions">
|
||||
<div className="sync-status">
|
||||
{synced ? "✓ Synced" : "⟳ Syncing..."}
|
||||
</div>
|
||||
{!shareToken && (
|
||||
<button className="share-btn" onClick={() => setShowShareModal(true)}>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +43,10 @@ const EditorPage = () => {
|
||||
<UserList awareness={providers.awareness} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showShareModal && (
|
||||
<ShareModal documentId={id!} onClose={() => setShowShareModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { DocumentType } from "../api/document.ts";
|
||||
import { documentsApi } from "../api/document.ts";
|
||||
import Navbar from "../components/Navbar.tsx";
|
||||
import PixelIcon from "../components/PixelIcon/PixelIcon.tsx";
|
||||
import FloatingGem from "../components/PixelSprites/FloatingGem.tsx";
|
||||
|
||||
@@ -57,12 +58,14 @@ const Home = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-page" style={{ position: 'relative' }}>
|
||||
<FloatingGem position={{ top: '20px', right: '40px' }} delay={0} size={40} />
|
||||
<FloatingGem position={{ top: '60px', left: '60px' }} delay={1.5} size={32} />
|
||||
<FloatingGem position={{ bottom: '100px', right: '100px' }} delay={3} size={36} />
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="home-page" style={{ position: 'relative' }}>
|
||||
<FloatingGem position={{ top: '20px', right: '40px' }} delay={0} size={40} />
|
||||
<FloatingGem position={{ top: '60px', left: '60px' }} delay={1.5} size={32} />
|
||||
<FloatingGem position={{ bottom: '100px', right: '100px' }} delay={3} size={36} />
|
||||
|
||||
<h1>My Documents</h1>
|
||||
<h1>My Documents</h1>
|
||||
|
||||
<div className="create-buttons">
|
||||
<button onClick={() => createDocument("editor")} disabled={creating}>
|
||||
@@ -105,7 +108,8 @@ const Home = () => {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import KanbanBoard from "../components/Kanban/KanbanBoard.tsx";
|
||||
import UserList from "../components/Presence/UserList.tsx";
|
||||
import ShareModal from "../components/Share/ShareModal.tsx";
|
||||
import Navbar from "../components/Navbar.tsx";
|
||||
import { useYjsDocument } from "../hooks/useYjsDocument.ts";
|
||||
|
||||
const KanbanPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { providers, synced } = useYjsDocument(id!);
|
||||
const shareToken = searchParams.get('share') || undefined;
|
||||
const { providers, synced } = useYjsDocument(id!, shareToken);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
if (!providers) {
|
||||
return <div className="loading">Connecting...</div>;
|
||||
@@ -14,10 +20,18 @@ const KanbanPage = () => {
|
||||
|
||||
return (
|
||||
<div className="kanban-page">
|
||||
<Navbar />
|
||||
<div className="page-header">
|
||||
<button onClick={() => navigate("/")}>← Back to Home</button>
|
||||
<div className="sync-status">
|
||||
{synced ? "✓ Synced" : "⟳ Syncing..."}
|
||||
<div className="header-actions">
|
||||
<div className="sync-status">
|
||||
{synced ? "✓ Synced" : "⟳ Syncing..."}
|
||||
</div>
|
||||
{!shareToken && (
|
||||
<button className="share-btn" onClick={() => setShowShareModal(true)}>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +43,10 @@ const KanbanPage = () => {
|
||||
<UserList awareness={providers.awareness} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showShareModal && (
|
||||
<ShareModal documentId={id!} onClose={() => setShowShareModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
83
frontend/src/pages/LoginPage.css
Normal file
83
frontend/src/pages/LoginPage.css
Normal file
@@ -0,0 +1,83 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 48px 40px;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 16px;
|
||||
color: #718096;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.login-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 14px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.google-button {
|
||||
background: #4285f4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.google-button:hover {
|
||||
background: #357ae8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(66, 133, 244, 0.3);
|
||||
}
|
||||
|
||||
.github-button {
|
||||
background: #24292e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.github-button:hover {
|
||||
background: #1a1f23;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(36, 41, 46, 0.3);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
86
frontend/src/pages/LoginPage.tsx
Normal file
86
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './LoginPage.css';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api";
|
||||
|
||||
function LoginPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, loading, navigate]);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
window.location.href = `${API_BASE_URL}/auth/google`;
|
||||
};
|
||||
|
||||
const handleGitHubLogin = () => {
|
||||
window.location.href = `${API_BASE_URL}/auth/github`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-container">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-container">
|
||||
<h1 className="login-title">Realtime Collab</h1>
|
||||
<p className="login-subtitle">Collaborate in real-time with your team</p>
|
||||
|
||||
<div className="login-buttons">
|
||||
<button
|
||||
className="login-button google-button"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<svg className="button-icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="login-button github-button"
|
||||
onClick={handleGitHubLogin}
|
||||
>
|
||||
<svg className="button-icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
20
frontend/src/types/auth.ts
Normal file
20
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url?: string;
|
||||
provider: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at?: string;
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (token: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
25
frontend/src/types/share.ts
Normal file
25
frontend/src/types/share.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { User } from './auth';
|
||||
|
||||
export interface DocumentShare {
|
||||
id: string;
|
||||
document_id: string;
|
||||
user_id: string;
|
||||
permission: 'view' | 'edit';
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface DocumentShareWithUser extends DocumentShare {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface CreateShareRequest {
|
||||
user_email: string;
|
||||
permission: 'view' | 'edit';
|
||||
}
|
||||
|
||||
export interface ShareLink {
|
||||
token: string;
|
||||
permission: 'view' | 'edit';
|
||||
created_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user