-
Notifications
You must be signed in to change notification settings - Fork 3
feat: complete auth surface with OAuth, header auth, shared URLs, logout hooks (#22) (closes #22) #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: complete auth surface with OAuth, header auth, shared URLs, logout hooks (#22) (closes #22) #33
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| import { useState } from 'react'; | ||
| import { Share2, Copy, Check, X } from 'lucide-react'; | ||
| import { Button } from '../components/ui/button'; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogDescription, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| DialogTrigger, | ||
| } from '../components/ui/dialog'; | ||
| import { Input } from '../components/ui/input'; | ||
| import { Label } from '../components/ui/label'; | ||
|
|
||
| interface ShareThreadButtonProps { | ||
| threadId: string; | ||
| className?: string; | ||
| } | ||
|
|
||
| export function ShareThreadButton({ threadId, className }: ShareThreadButtonProps) { | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [shareUrl, setShareUrl] = useState<string>(''); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const [isCopied, setIsCopied] = useState(false); | ||
| const [error, setError] = useState<string>(''); | ||
|
|
||
| const createShareLink = async () => { | ||
| setIsLoading(true); | ||
| setError(''); | ||
|
|
||
| try { | ||
| const token = localStorage.getItem('token'); | ||
| if (!token) { | ||
| setError('Authentication required'); | ||
| return; | ||
| } | ||
|
|
||
| const response = await fetch(`/api/threads/${threadId}/share`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Authorization': `Bearer ${token}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const errorData = await response.json(); | ||
| throw new Error(errorData.error || 'Failed to create share link'); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| setShareUrl(data.url); | ||
| } catch (err) { | ||
| setError(err instanceof Error ? err.message : 'Failed to create share link'); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| const revokeShareLink = async () => { | ||
| setIsLoading(true); | ||
| setError(''); | ||
|
|
||
| try { | ||
| const token = localStorage.getItem('token'); | ||
| if (!token) { | ||
| setError('Authentication required'); | ||
| return; | ||
| } | ||
|
|
||
| const response = await fetch(`/api/threads/${threadId}/unshare`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Authorization': `Bearer ${token}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const errorData = await response.json(); | ||
| throw new Error(errorData.error || 'Failed to revoke share link'); | ||
| } | ||
|
|
||
| setShareUrl(''); | ||
| setIsCopied(false); | ||
| } catch (err) { | ||
| setError(err instanceof Error ? err.message : 'Failed to revoke share link'); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| const copyToClipboard = async () => { | ||
| try { | ||
| await navigator.clipboard.writeText(shareUrl); | ||
| setIsCopied(true); | ||
| setTimeout(() => setIsCopied(false), 2000); | ||
| } catch (err) { | ||
| console.error('Failed to copy to clipboard:', err); | ||
| } | ||
| }; | ||
|
|
||
| const handleOpenChange = (open: boolean) => { | ||
| setIsOpen(open); | ||
| if (open && !shareUrl) { | ||
| createShareLink(); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <Dialog open={isOpen} onOpenChange={handleOpenChange}> | ||
| <DialogTrigger asChild> | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| className={className} | ||
| disabled={isLoading} | ||
| > | ||
| <Share2 className="h-4 w-4 mr-1" /> | ||
| Share | ||
| </Button> | ||
| </DialogTrigger> | ||
| <DialogContent className="sm:max-w-md"> | ||
| <DialogHeader> | ||
| <DialogTitle>Share Thread</DialogTitle> | ||
| <DialogDescription> | ||
| Anyone with this link will be able to view this conversation in read-only mode. | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
|
|
||
| <div className="space-y-4"> | ||
| {error && ( | ||
| <div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md"> | ||
| {error} | ||
| </div> | ||
| )} | ||
|
|
||
| {shareUrl ? ( | ||
| <div className="space-y-3"> | ||
| <div> | ||
| <Label htmlFor="share-url" className="text-sm font-medium"> | ||
| Share URL | ||
| </Label> | ||
| <div className="flex space-x-2 mt-1"> | ||
| <Input | ||
| id="share-url" | ||
| readOnly | ||
| value={shareUrl} | ||
| className="flex-1" | ||
| /> | ||
| <Button | ||
| type="button" | ||
| size="icon" | ||
| variant="outline" | ||
| onClick={copyToClipboard} | ||
| disabled={!shareUrl} | ||
| > | ||
| {isCopied ? ( | ||
| <Check className="h-4 w-4 text-green-600" /> | ||
| ) : ( | ||
| <Copy className="h-4 w-4" /> | ||
| )} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex justify-between"> | ||
| <Button | ||
| type="button" | ||
| variant="destructive" | ||
| size="sm" | ||
| onClick={revokeShareLink} | ||
| disabled={isLoading} | ||
| > | ||
| <X className="h-4 w-4 mr-1" /> | ||
| Revoke Link | ||
| </Button> | ||
|
|
||
| <div className="flex space-x-2"> | ||
| <Button | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => setIsOpen(false)} | ||
| > | ||
| Close | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ) : ( | ||
| <div className="flex items-center justify-center py-8"> | ||
| {isLoading ? ( | ||
| <div className="text-sm text-gray-500">Creating share link...</div> | ||
| ) : ( | ||
| <Button onClick={createShareLink}> | ||
| Create Share Link | ||
| </Button> | ||
| )} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,160 @@ | ||||||||||||
| import { useEffect, useState } from 'react'; | ||||||||||||
| import { useParams } from 'react-router-dom'; | ||||||||||||
| import { Eye, AlertCircle } from 'lucide-react'; | ||||||||||||
| import { ChatMessages } from '../chat/ChatMessages'; | ||||||||||||
| import { Button } from '../components/ui/button'; | ||||||||||||
|
|
||||||||||||
| interface SharedThreadData { | ||||||||||||
| thread_id: string; | ||||||||||||
| session: { | ||||||||||||
| id: string; | ||||||||||||
| title: string; | ||||||||||||
| created_at: string; | ||||||||||||
| }; | ||||||||||||
| messages: Array<{ | ||||||||||||
| id: string; | ||||||||||||
| role: string; | ||||||||||||
| content: string; | ||||||||||||
| timestamp: string; | ||||||||||||
| toolCalls?: any[]; | ||||||||||||
| }>; | ||||||||||||
| read_only: boolean; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function SharedThread() { | ||||||||||||
| const { token } = useParams<{ token: string }>(); | ||||||||||||
| const [threadData, setThreadData] = useState<SharedThreadData | null>(null); | ||||||||||||
| const [isLoading, setIsLoading] = useState(true); | ||||||||||||
| const [error, setError] = useState<string>(''); | ||||||||||||
|
|
||||||||||||
| useEffect(() => { | ||||||||||||
| const fetchSharedThread = async () => { | ||||||||||||
| if (!token) { | ||||||||||||
| setError('Invalid share link'); | ||||||||||||
| setIsLoading(false); | ||||||||||||
| return; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| try { | ||||||||||||
| const response = await fetch(`/shared/${token}`, { | ||||||||||||
| headers: { | ||||||||||||
| 'Accept': 'application/json', | ||||||||||||
| }, | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| if (!response.ok) { | ||||||||||||
| const errorData = await response.json(); | ||||||||||||
| throw new Error(errorData.error || 'Failed to load shared thread'); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const data = await response.json(); | ||||||||||||
| setThreadData(data); | ||||||||||||
| } catch (err) { | ||||||||||||
| setError(err instanceof Error ? err.message : 'Failed to load shared thread'); | ||||||||||||
| } finally { | ||||||||||||
| setIsLoading(false); | ||||||||||||
| } | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| fetchSharedThread(); | ||||||||||||
| }, [token]); | ||||||||||||
|
|
||||||||||||
| if (isLoading) { | ||||||||||||
| return ( | ||||||||||||
| <div className="min-h-screen bg-gray-50 flex items-center justify-center"> | ||||||||||||
| <div className="text-center"> | ||||||||||||
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div> | ||||||||||||
| <div className="text-sm text-gray-600">Loading shared conversation...</div> | ||||||||||||
| </div> | ||||||||||||
| </div> | ||||||||||||
| ); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (error) { | ||||||||||||
| return ( | ||||||||||||
| <div className="min-h-screen bg-gray-50 flex items-center justify-center"> | ||||||||||||
| <div className="max-w-md w-full bg-white rounded-lg shadow-sm border border-gray-200 p-6 text-center"> | ||||||||||||
| <AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" /> | ||||||||||||
| <h2 className="text-lg font-semibold text-gray-900 mb-2"> | ||||||||||||
| Unable to Load Shared Thread | ||||||||||||
| </h2> | ||||||||||||
| <p className="text-sm text-gray-600 mb-4">{error}</p> | ||||||||||||
| <Button | ||||||||||||
| onClick={() => window.location.href = '/'} | ||||||||||||
| variant="outline" | ||||||||||||
| > | ||||||||||||
| Go Home | ||||||||||||
| </Button> | ||||||||||||
| </div> | ||||||||||||
| </div> | ||||||||||||
| ); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (!threadData) { | ||||||||||||
| return null; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| return ( | ||||||||||||
| <div className="min-h-screen bg-gray-50"> | ||||||||||||
| {/* Header */} | ||||||||||||
| <header className="bg-white border-b border-gray-200"> | ||||||||||||
| <div className="max-w-4xl mx-auto px-4 py-4"> | ||||||||||||
| <div className="flex items-center justify-between"> | ||||||||||||
| <div className="flex items-center space-x-3"> | ||||||||||||
| <Eye className="h-5 w-5 text-gray-500" /> | ||||||||||||
| <div> | ||||||||||||
| <h1 className="text-lg font-semibold text-gray-900"> | ||||||||||||
| {threadData.session.title || 'Shared Conversation'} | ||||||||||||
| </h1> | ||||||||||||
| <p className="text-sm text-gray-500"> | ||||||||||||
| Read-only view • Created {new Date(threadData.session.created_at).toLocaleDateString()} | ||||||||||||
| </p> | ||||||||||||
| </div> | ||||||||||||
| </div> | ||||||||||||
|
|
||||||||||||
| <Button | ||||||||||||
| onClick={() => window.location.href = '/'} | ||||||||||||
| variant="outline" | ||||||||||||
| size="sm" | ||||||||||||
| > | ||||||||||||
| Start New Chat | ||||||||||||
| </Button> | ||||||||||||
| </div> | ||||||||||||
| </div> | ||||||||||||
| </header> | ||||||||||||
|
|
||||||||||||
| {/* Messages */} | ||||||||||||
| <main className="max-w-4xl mx-auto px-4 py-6"> | ||||||||||||
| <div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"> | ||||||||||||
| {threadData.messages.length > 0 ? ( | ||||||||||||
| <div className="divide-y divide-gray-100"> | ||||||||||||
| <ChatMessages | ||||||||||||
| messages={threadData.messages} | ||||||||||||
| sessionId={threadData.thread_id} | ||||||||||||
| isSharedView={true} | ||||||||||||
|
||||||||||||
| isSharedView={true} | |
| currentResponse="" | |
| thinkingSteps={[]} | |
| toolCalls={[]} | |
| isStreaming={false} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SharedThread.tsxdepends onreact-router-dom(useParams), but the frontendpackage.jsonhas noreact-router-domdependency and the app doesn’t appear to be using a router elsewhere. Either add routing support + dependency, or derive the token fromwindow.location.pathnameso this page works in the current SPA architecture.