enabling login btw users
This commit is contained in:
63
raggr-frontend/TOKEN_REFRESH_IMPLEMENTATION.md
Normal file
63
raggr-frontend/TOKEN_REFRESH_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Token Refresh Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
The API services now automatically handle token refresh when access tokens expire. This provides a seamless user experience without requiring manual re-authentication.
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. **userService.ts**
|
||||
|
||||
The `userService` now includes:
|
||||
|
||||
- **`refreshToken()`**: Automatically gets the refresh token from localStorage, calls the `/api/user/refresh` endpoint, and updates the access token
|
||||
- **`fetchWithAuth()`**: A wrapper around `fetch()` that:
|
||||
1. Automatically adds the Authorization header with the access token
|
||||
2. Detects 401 (Unauthorized) responses
|
||||
3. Automatically refreshes the token using the refresh token
|
||||
4. Retries the original request with the new access token
|
||||
5. Throws an error if refresh fails (e.g., refresh token expired)
|
||||
|
||||
### 2. **conversationService.ts**
|
||||
|
||||
Now uses `userService.fetchWithAuth()` for all API calls:
|
||||
- `sendQuery()` - No longer needs token parameter
|
||||
- `getMessages()` - No longer needs token parameter
|
||||
|
||||
### 3. **Components Updated**
|
||||
|
||||
**ChatScreen.tsx**:
|
||||
- Removed manual token handling
|
||||
- Now simply calls `conversationService.sendQuery(query)` and `conversationService.getMessages()`
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Automatic token refresh** - Users stay logged in longer
|
||||
✅ **Transparent retry logic** - Failed requests due to expired tokens are automatically retried
|
||||
✅ **Cleaner code** - Components don't need to manage tokens
|
||||
✅ **Better UX** - No interruptions when access token expires
|
||||
✅ **Centralized auth logic** - All auth handling in one place
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If refresh token is missing or invalid, the error is thrown
|
||||
- Components can catch these errors and redirect to login
|
||||
- LocalStorage is automatically cleared when refresh fails
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
// Old way (manual token management)
|
||||
const token = localStorage.getItem("access_token");
|
||||
const result = await conversationService.sendQuery(query, token);
|
||||
|
||||
// New way (automatic token refresh)
|
||||
const result = await conversationService.sendQuery(query);
|
||||
```
|
||||
|
||||
## Token Storage
|
||||
|
||||
- **Access Token**: `localStorage.getItem("access_token")`
|
||||
- **Refresh Token**: `localStorage.getItem("refresh_token")`
|
||||
|
||||
Both are automatically managed by the services.
|
||||
Binary file not shown.
@@ -1,203 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import "./App.css";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { ChatScreen } from "./components/ChatScreen";
|
||||
import { LoginScreen } from "./components/LoginScreen";
|
||||
import { conversationService } from "./api/conversationService";
|
||||
|
||||
type QuestionAnswer = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
const AppContainer = () => {
|
||||
const [isAuthenticated, setAuthenticated] = useState<boolean>(false);
|
||||
const [isChecking, setIsChecking] = useState<boolean>(true);
|
||||
|
||||
type QuestionBubbleProps = {
|
||||
text: string;
|
||||
};
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
type AnswerBubbleProps = {
|
||||
text: string;
|
||||
loading: string;
|
||||
};
|
||||
// No tokens at all, not authenticated
|
||||
if (!accessToken && !refreshToken) {
|
||||
setIsChecking(false);
|
||||
setAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
type QuestionAnswerPairProps = {
|
||||
question: string;
|
||||
answer: string;
|
||||
loading: boolean;
|
||||
};
|
||||
// Try to verify token by making a request
|
||||
try {
|
||||
await conversationService.getMessages();
|
||||
// If successful, user is authenticated
|
||||
setAuthenticated(true);
|
||||
} catch (error) {
|
||||
// Token is invalid or expired
|
||||
console.error("Authentication check failed:", error);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
setAuthenticated(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
type Message = {
|
||||
text: string;
|
||||
speaker: "simba" | "user";
|
||||
};
|
||||
// Show loading state while checking authentication
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-white/85">
|
||||
<div className="text-xl">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConversationMenuProps = {
|
||||
conversations: Conversation[];
|
||||
};
|
||||
|
||||
const ConversationMenu = ({ conversations }: ConversationMenuProps) => {
|
||||
return (
|
||||
<div className="absolute bg-white w-md rounded-md shadow-xl m-4 p-4">
|
||||
<p className="py-2 px-4 rounded-md w-full text-xl font-bold">askSimba!</p>
|
||||
{conversations.map((conversation) => (
|
||||
<p className="py-2 px-4 rounded-md hover:bg-stone-200 w-full text-xl font-bold cursor-pointer">
|
||||
{conversation.title}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QuestionBubble = ({ text }: QuestionBubbleProps) => {
|
||||
return <div className="rounded-md bg-stone-200 p-3">🤦: {text}</div>;
|
||||
};
|
||||
|
||||
const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
||||
return (
|
||||
<div className="rounded-md bg-orange-100 p-3">
|
||||
{loading ? (
|
||||
<div className="flex flex-col w-full animate-pulse gap-2">
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
||||
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
<div className="bg-gray-400 w-1/3 p-3 rounded-lg" />
|
||||
<div className="bg-gray-400 w-2/3 p-3 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<ChatScreen setAuthenticated={setAuthenticated} />
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<ReactMarkdown>{"🐈: " + text}</ReactMarkdown>
|
||||
</div>
|
||||
<LoginScreen setAuthenticated={setAuthenticated} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QuestionAnswerPair = ({
|
||||
question,
|
||||
answer,
|
||||
loading,
|
||||
}: QuestionAnswerPairProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<QuestionBubble text={question} />
|
||||
<AnswerBubble text={answer} loading={loading} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [answer, setAnswer] = useState<string>("");
|
||||
const [simbaMode, setSimbaMode] = useState<boolean>(false);
|
||||
const [questionsAnswers, setQuestionsAnswers] = useState<QuestionAnswer[]>(
|
||||
[],
|
||||
);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([
|
||||
{ title: "simba meow meow", id: "uuid" },
|
||||
]);
|
||||
|
||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||
|
||||
useEffect(() => {
|
||||
axios.get("/api/messages").then((result) => {
|
||||
setMessages(
|
||||
result.data.messages.map((message) => {
|
||||
return {
|
||||
text: message.text,
|
||||
speaker: message.speaker,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleQuestionSubmit = () => {
|
||||
let currMessages = messages.concat([{ text: query, speaker: "user" }]);
|
||||
setMessages(currMessages);
|
||||
if (simbaMode) {
|
||||
console.log("simba mode activated");
|
||||
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
|
||||
const randomElement = simbaAnswers[randomIndex];
|
||||
setAnswer(randomElement);
|
||||
setQuestionsAnswers(
|
||||
questionsAnswers.concat([
|
||||
{
|
||||
question: query,
|
||||
answer: randomElement,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = { query: query };
|
||||
axios.post("/api/query", payload).then((result) => {
|
||||
setQuestionsAnswers(
|
||||
questionsAnswers.concat([
|
||||
{ question: query, answer: result.data.response },
|
||||
]),
|
||||
);
|
||||
setMessages(
|
||||
currMessages.concat([{ text: result.data.response, speaker: "simba" }]),
|
||||
);
|
||||
});
|
||||
};
|
||||
const handleQueryChange = (event) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
return (
|
||||
<div className="h-screen bg-opacity-20">
|
||||
<div className="bg-white/85 h-screen">
|
||||
<div className="flex flex-row justify-center py-4">
|
||||
<div className="flex flex-col gap-4 min-w-xl max-w-xl">
|
||||
<header className="flex flex-row justify-center gap-2 grow sticky top-0 z-10 bg-white">
|
||||
<h1 className="text-3xl">ask simba!</h1>
|
||||
</header>
|
||||
{/*{questionsAnswers.map((qa) => (
|
||||
<QuestionAnswerPair question={qa.question} answer={qa.answer} />
|
||||
))}*/}
|
||||
{messages.map((msg) => {
|
||||
if (msg.speaker == "simba") {
|
||||
return <AnswerBubble text={msg.text} loading="" />;
|
||||
}
|
||||
|
||||
return <QuestionBubble text={msg.text} />;
|
||||
})}
|
||||
<footer className="flex flex-col gap-2 sticky bottom-0">
|
||||
<div className="flex flex-row justify-between gap-2 grow">
|
||||
<textarea
|
||||
type="text"
|
||||
className="p-4 border border-blue-200 rounded-md grow bg-white"
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between gap-2 grow">
|
||||
<button
|
||||
className="p-4 border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow"
|
||||
onClick={() => handleQuestionSubmit()}
|
||||
type="submit"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-center gap-2 grow">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(event) => setSimbaMode(event.target.checked)}
|
||||
/>
|
||||
<p>simba mode?</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthProvider>
|
||||
<AppContainer />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
61
raggr-frontend/src/api/conversationService.ts
Normal file
61
raggr-frontend/src/api/conversationService.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { userService } from "./userService";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
speaker: "user" | "simba";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
name: string;
|
||||
messages: Message[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface QueryRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface QueryResponse {
|
||||
response: string;
|
||||
}
|
||||
|
||||
class ConversationService {
|
||||
private baseUrl = "/api";
|
||||
|
||||
async sendQuery(query: string): Promise<QueryResponse> {
|
||||
const response = await userService.fetchWithRefreshToken(
|
||||
`${this.baseUrl}/query`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send query");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getMessages(): Promise<Conversation> {
|
||||
const response = await userService.fetchWithRefreshToken(
|
||||
`${this.baseUrl}/messages`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch messages");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationService = new ConversationService();
|
||||
123
raggr-frontend/src/api/userService.ts
Normal file
123
raggr-frontend/src/api/userService.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RefreshResponse {
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
class UserService {
|
||||
private baseUrl = "/api/user";
|
||||
|
||||
async login(username: string, password: string): Promise<LoginResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async refreshToken(): Promise<string> {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Refresh token is invalid or expired, clear storage
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
throw new Error("Failed to refresh token");
|
||||
}
|
||||
|
||||
const data: RefreshResponse = await response.json();
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async fetchWithAuth(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
|
||||
// Add authorization header
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
// If unauthorized, try refreshing the token
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
const newAccessToken = await this.refreshToken();
|
||||
|
||||
// Retry the request with new token
|
||||
headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
response = await fetch(url, { ...options, headers });
|
||||
} catch (error) {
|
||||
// Refresh failed, redirect to login or throw error
|
||||
throw new Error("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async fetchWithRefreshToken(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
// Add authorization header
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
...(refreshToken && { Authorization: `Bearer ${refreshToken}` }),
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
// If unauthorized, try refreshing the token
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
const newAccessToken = await this.refreshToken();
|
||||
|
||||
// Retry the request with new token
|
||||
headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
response = await fetch(url, { ...options, headers });
|
||||
} catch (error) {
|
||||
// Refresh failed, redirect to login or throw error
|
||||
throw new Error("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
29
raggr-frontend/src/components/AnswerBubble.tsx
Normal file
29
raggr-frontend/src/components/AnswerBubble.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
type AnswerBubbleProps = {
|
||||
text: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
||||
return (
|
||||
<div className="rounded-md bg-orange-100 p-3">
|
||||
{loading ? (
|
||||
<div className="flex flex-col w-full animate-pulse gap-2">
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
||||
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
<div className="bg-gray-400 w-1/3 p-3 rounded-lg" />
|
||||
<div className="bg-gray-400 w-2/3 p-3 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<ReactMarkdown>{"🐈: " + text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
raggr-frontend/src/components/ChatScreen.tsx
Normal file
150
raggr-frontend/src/components/ChatScreen.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import { QuestionBubble } from "./QuestionBubble";
|
||||
import { AnswerBubble } from "./AnswerBubble";
|
||||
|
||||
type Message = {
|
||||
text: string;
|
||||
speaker: "simba" | "user";
|
||||
};
|
||||
|
||||
type QuestionAnswer = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ChatScreenProps = {
|
||||
setAuthenticated: (isAuth: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [answer, setAnswer] = useState<string>("");
|
||||
const [simbaMode, setSimbaMode] = useState<boolean>(false);
|
||||
const [questionsAnswers, setQuestionsAnswers] = useState<QuestionAnswer[]>(
|
||||
[],
|
||||
);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([
|
||||
{ title: "simba meow meow", id: "uuid" },
|
||||
]);
|
||||
|
||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||
|
||||
useEffect(() => {
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
const conversation = await conversationService.getMessages();
|
||||
setMessages(
|
||||
conversation.messages.map((message) => ({
|
||||
text: message.text,
|
||||
speaker: message.speaker,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error);
|
||||
}
|
||||
};
|
||||
loadMessages();
|
||||
}, []);
|
||||
|
||||
const handleQuestionSubmit = async () => {
|
||||
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
|
||||
setMessages(currMessages);
|
||||
|
||||
if (simbaMode) {
|
||||
console.log("simba mode activated");
|
||||
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
|
||||
const randomElement = simbaAnswers[randomIndex];
|
||||
setAnswer(randomElement);
|
||||
setQuestionsAnswers(
|
||||
questionsAnswers.concat([
|
||||
{
|
||||
question: query,
|
||||
answer: randomElement,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await conversationService.sendQuery(query);
|
||||
setQuestionsAnswers(
|
||||
questionsAnswers.concat([{ question: query, answer: result.response }]),
|
||||
);
|
||||
setMessages(
|
||||
currMessages.concat([{ text: result.response, speaker: "simba" }]),
|
||||
);
|
||||
setQuery(""); // Clear input after successful send
|
||||
} catch (error) {
|
||||
console.error("Failed to send query:", error);
|
||||
// If session expired, redirect to login
|
||||
if (error instanceof Error && error.message.includes("Session expired")) {
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-opacity-20">
|
||||
<div className="bg-white/85 h-screen">
|
||||
<div className="flex flex-row justify-center py-4">
|
||||
<div className="flex flex-col gap-4 min-w-xl max-w-xl">
|
||||
<div className="flex flex-row justify-between">
|
||||
<header className="flex flex-row justify-center gap-2 grow sticky top-0 z-10 bg-white">
|
||||
<h1 className="text-3xl">ask simba!</h1>
|
||||
</header>
|
||||
<button
|
||||
className="p-4 border border-red-400 bg-red-200 hover:bg-red-400 cursor-pointer rounded-md"
|
||||
onClick={() => setAuthenticated(false)}
|
||||
>
|
||||
logout
|
||||
</button>
|
||||
</div>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.speaker === "simba") {
|
||||
return <AnswerBubble key={index} text={msg.text} />;
|
||||
}
|
||||
return <QuestionBubble key={index} text={msg.text} />;
|
||||
})}
|
||||
<footer className="flex flex-col gap-2 sticky bottom-0">
|
||||
<div className="flex flex-row justify-between gap-2 grow">
|
||||
<textarea
|
||||
className="p-4 border border-blue-200 rounded-md grow bg-white"
|
||||
onChange={handleQueryChange}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between gap-2 grow">
|
||||
<button
|
||||
className="p-4 border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow"
|
||||
onClick={() => handleQuestionSubmit()}
|
||||
type="submit"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-center gap-2 grow">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(event) => setSimbaMode(event.target.checked)}
|
||||
/>
|
||||
<p>simba mode?</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
24
raggr-frontend/src/components/ConversationMenu.tsx
Normal file
24
raggr-frontend/src/components/ConversationMenu.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ConversationMenuProps = {
|
||||
conversations: Conversation[];
|
||||
};
|
||||
|
||||
export const ConversationMenu = ({ conversations }: ConversationMenuProps) => {
|
||||
return (
|
||||
<div className="absolute bg-white w-md rounded-md shadow-xl m-4 p-4">
|
||||
<p className="py-2 px-4 rounded-md w-full text-xl font-bold">askSimba!</p>
|
||||
{conversations.map((conversation) => (
|
||||
<p
|
||||
key={conversation.id}
|
||||
className="py-2 px-4 rounded-md hover:bg-stone-200 w-full text-xl font-bold cursor-pointer"
|
||||
>
|
||||
{conversation.title}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
80
raggr-frontend/src/components/LoginScreen.tsx
Normal file
80
raggr-frontend/src/components/LoginScreen.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { userService } from "../api/userService";
|
||||
|
||||
type LoginScreenProps = {
|
||||
setAuthenticated: (isAuth: boolean) => void;
|
||||
};
|
||||
|
||||
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
setError("Please enter username and password");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await userService.login(username, password);
|
||||
localStorage.setItem("access_token", result.access_token);
|
||||
localStorage.setItem("refresh_token", result.refresh_token);
|
||||
setAuthenticated(true);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError("Login failed. Please check your credentials.");
|
||||
console.error("Login error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-opacity-20">
|
||||
<div className="bg-white/85 h-screen">
|
||||
<div className="flex flex-row justify-center py-4">
|
||||
<div className="flex flex-col gap-4 min-w-xl max-w-xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-grow justify-center w-full bg-amber-400">
|
||||
<h1 className="text-xl font-bold">
|
||||
I AM LOOKING FOR A DESIGNER. THIS APP WILL REMAIN UGLY UNTIL A
|
||||
DESIGNER COMES.
|
||||
</h1>
|
||||
</div>
|
||||
<header className="flex flex-row justify-center gap-2 grow sticky top-0 z-10 bg-white">
|
||||
<h1 className="text-3xl">ask simba!</h1>
|
||||
</header>
|
||||
<label htmlFor="username">username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="border border-s-slate-950 p-3 rounded-md"
|
||||
/>
|
||||
<label htmlFor="password">password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="border border-s-slate-950 p-3 rounded-md"
|
||||
/>
|
||||
{error && (
|
||||
<div className="text-red-600 font-semibold">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-4 border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow"
|
||||
onClick={handleLogin}
|
||||
>
|
||||
login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
raggr-frontend/src/components/QuestionBubble.tsx
Normal file
7
raggr-frontend/src/components/QuestionBubble.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
type QuestionBubbleProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const QuestionBubble = ({ text }: QuestionBubbleProps) => {
|
||||
return <div className="rounded-md bg-stone-200 p-3">🤦: {text}</div>;
|
||||
};
|
||||
56
raggr-frontend/src/contexts/AuthContext.tsx
Normal file
56
raggr-frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
import { userService } from "../api/userService";
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
login: (username: string, password: string) => Promise<any>;
|
||||
logout: () => void;
|
||||
isAuthenticated: () => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [token, setToken] = useState(localStorage.getItem("access_token"));
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const data = await userService.login(username, password);
|
||||
setToken(data.access_token);
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
};
|
||||
|
||||
const isAuthenticated = () => {
|
||||
return token !== null && token !== undefined && token !== "";
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, login, logout, isAuthenticated }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user