aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-11-02 00:00:35 +0900
committernsfisis <nsfisis@gmail.com>2025-11-02 00:00:35 +0900
commit104341ddc4add57f83c58cb3fabb23b6fbfdd3e4 (patch)
tree862b109fe257e6170a88929729dae3bddfb6eb49 /frontend/src
parentba1e0c904f810193f25d4f88cc2bb168f1d625fe (diff)
downloadfeedaka-104341ddc4add57f83c58cb3fabb23b6fbfdd3e4.tar.gz
feedaka-104341ddc4add57f83c58cb3fabb23b6fbfdd3e4.tar.zst
feedaka-104341ddc4add57f83c58cb3fabb23b6fbfdd3e4.zip
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.tsx35
-rw-r--r--frontend/src/components/Navigation.tsx26
-rw-r--r--frontend/src/components/ProtectedRoute.tsx32
-rw-r--r--frontend/src/components/index.ts1
-rw-r--r--frontend/src/contexts/AuthContext.tsx95
-rw-r--r--frontend/src/graphql/generated/gql.ts12
-rw-r--r--frontend/src/graphql/generated/graphql.ts50
-rw-r--r--frontend/src/graphql/mutations.graphql13
-rw-r--r--frontend/src/graphql/queries.graphql7
-rw-r--r--frontend/src/main.tsx9
-rw-r--r--frontend/src/pages/Login.tsx133
-rw-r--r--frontend/src/pages/index.ts1
-rw-r--r--frontend/src/services/graphql-client.ts4
13 files changed, 395 insertions, 23 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 02db0ca..58b6687 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,18 +1,31 @@
import { Redirect, Route, Switch } from "wouter";
-import { Layout } from "./components";
-import { NotFound, ReadArticles, Settings, UnreadArticles } from "./pages";
+import { Layout, ProtectedRoute } from "./components";
+import {
+ Login,
+ NotFound,
+ ReadArticles,
+ Settings,
+ UnreadArticles,
+} from "./pages";
function App() {
return (
- <Layout>
- <Switch>
- <Route path="/" component={() => <Redirect to="/unread" />} />
- <Route path="/unread" component={UnreadArticles} />
- <Route path="/read" component={ReadArticles} />
- <Route path="/settings" component={Settings} />
- <Route component={NotFound} />
- </Switch>
- </Layout>
+ <Switch>
+ <Route path="/login" component={Login} />
+ <Route path="*">
+ <ProtectedRoute>
+ <Layout>
+ <Switch>
+ <Route path="/" component={() => <Redirect to="/unread" />} />
+ <Route path="/unread" component={UnreadArticles} />
+ <Route path="/read" component={ReadArticles} />
+ <Route path="/settings" component={Settings} />
+ <Route component={NotFound} />
+ </Switch>
+ </Layout>
+ </ProtectedRoute>
+ </Route>
+ </Switch>
);
}
diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx
index 08a523f..d6e20c9 100644
--- a/frontend/src/components/Navigation.tsx
+++ b/frontend/src/components/Navigation.tsx
@@ -2,11 +2,22 @@ import {
faBookOpen,
faCircleCheck,
faGear,
+ faRightFromBracket,
} from "@fortawesome/free-solid-svg-icons";
-import { Link } from "wouter";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Link, useLocation } from "wouter";
+import { useAuth } from "../contexts/AuthContext";
import { MenuItem } from "./MenuItem";
export function Navigation() {
+ const { logout, user } = useAuth();
+ const [, setLocation] = useLocation();
+
+ const handleLogout = async () => {
+ await logout();
+ setLocation("/login");
+ };
+
return (
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="container mx-auto px-4">
@@ -14,10 +25,21 @@ export function Navigation() {
<Link href="/" className="text-xl font-bold text-gray-900">
feedaka
</Link>
- <div className="flex space-x-6">
+ <div className="flex items-center space-x-6">
<MenuItem path="/unread" label="Unread" icon={faBookOpen} />
<MenuItem path="/read" label="Read" icon={faCircleCheck} />
<MenuItem path="/settings" label="Settings" icon={faGear} />
+ {user && (
+ <button
+ type="button"
+ onClick={handleLogout}
+ className="flex items-center space-x-2 text-gray-600 hover:text-gray-900"
+ title={`Logout (${user.username})`}
+ >
+ <FontAwesomeIcon icon={faRightFromBracket} />
+ <span>Logout</span>
+ </button>
+ )}
</div>
</div>
</div>
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..0cfef42
--- /dev/null
+++ b/frontend/src/components/ProtectedRoute.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import { Redirect } from "wouter";
+import { useAuth } from "../contexts/AuthContext";
+
+interface Props {
+ children: ReactNode;
+}
+
+export function ProtectedRoute({ children }: Props) {
+ const { user, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ minHeight: "100vh",
+ }}
+ >
+ Loading...
+ </div>
+ );
+ }
+
+ if (!user) {
+ return <Redirect to="/login" />;
+ }
+
+ return <>{children}</>;
+}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index 8253800..06f4c29 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -4,3 +4,4 @@ export { FeedList } from "./FeedList";
export { Layout } from "./Layout";
export { MenuItem } from "./MenuItem";
export { Navigation } from "./Navigation";
+export { ProtectedRoute } from "./ProtectedRoute";
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000..4f97355
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -0,0 +1,95 @@
+import {
+ createContext,
+ type ReactNode,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { useMutation, useQuery } from "urql";
+import {
+ GetMeDocument,
+ LoginDocument,
+ LogoutDocument,
+} from "../graphql/generated/graphql";
+
+interface User {
+ id: string;
+ username: string;
+}
+
+interface AuthContextType {
+ user: User | null;
+ isLoading: boolean;
+ login: (username: string, password: string) => Promise<boolean>;
+ logout: () => Promise<void>;
+}
+
+const AuthContext = createContext<AuthContextType | undefined>(undefined);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState<User | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const [, executeLogin] = useMutation(LoginDocument);
+ const [, executeLogout] = useMutation(LogoutDocument);
+ const [meResult, reexecuteMe] = useQuery({ query: GetMeDocument });
+
+ // Update user from Me query
+ useEffect(() => {
+ if (meResult.data?.me) {
+ setUser(meResult.data.me);
+ } else {
+ setUser(null);
+ }
+ if (!meResult.fetching) {
+ setIsLoading(false);
+ }
+ }, [meResult.data, meResult.fetching]);
+
+ const login = async (
+ username: string,
+ password: string,
+ ): Promise<boolean> => {
+ try {
+ const result = await executeLogin({ username, password });
+
+ if (result.data?.login?.user) {
+ setUser(result.data.login.user);
+ // Refetch Me query to ensure session is established
+ reexecuteMe({ requestPolicy: "network-only" });
+ return true;
+ }
+
+ return false;
+ } catch (error) {
+ console.error("Login failed:", error);
+ return false;
+ }
+ };
+
+ const logout = async () => {
+ try {
+ await executeLogout({});
+ } catch (error) {
+ console.error("Logout failed:", error);
+ } finally {
+ setUser(null);
+ // Refetch Me query to ensure session is cleared
+ reexecuteMe({ requestPolicy: "network-only" });
+ }
+ };
+
+ return (
+ <AuthContext.Provider value={{ user, isLoading, login, logout }}>
+ {children}
+ </AuthContext.Provider>
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+}
diff --git a/frontend/src/graphql/generated/gql.ts b/frontend/src/graphql/generated/gql.ts
index b0b965d..c56d986 100644
--- a/frontend/src/graphql/generated/gql.ts
+++ b/frontend/src/graphql/generated/gql.ts
@@ -14,12 +14,12 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
- "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}": typeof types.AddFeedDocument,
- "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}": typeof types.GetFeedsDocument,
+ "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation Login($username: String!, $password: String!) {\n login(username: $username, password: $password) {\n user {\n id\n username\n }\n }\n}\n\nmutation Logout {\n logout\n}": typeof types.AddFeedDocument,
+ "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetMe {\n me {\n id\n username\n }\n}": typeof types.GetFeedsDocument,
};
const documents: Documents = {
- "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}": types.AddFeedDocument,
- "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}": types.GetFeedsDocument,
+ "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation Login($username: String!, $password: String!) {\n login(username: $username, password: $password) {\n user {\n id\n username\n }\n }\n}\n\nmutation Logout {\n logout\n}": types.AddFeedDocument,
+ "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetMe {\n me {\n id\n username\n }\n}": types.GetFeedsDocument,
};
/**
@@ -39,11 +39,11 @@ export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}"): (typeof documents)["mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}"];
+export function graphql(source: "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation Login($username: String!, $password: String!) {\n login(username: $username, password: $password) {\n user {\n id\n username\n }\n }\n}\n\nmutation Logout {\n logout\n}"): (typeof documents)["mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation Login($username: String!, $password: String!) {\n login(username: $username, password: $password) {\n user {\n id\n username\n }\n }\n}\n\nmutation Logout {\n logout\n}"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}"): (typeof documents)["query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}"];
+export function graphql(source: "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetMe {\n me {\n id\n username\n }\n}"): (typeof documents)["query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetMe {\n me {\n id\n username\n }\n}"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
diff --git a/frontend/src/graphql/generated/graphql.ts b/frontend/src/graphql/generated/graphql.ts
index 22b34c5..503a986 100644
--- a/frontend/src/graphql/generated/graphql.ts
+++ b/frontend/src/graphql/generated/graphql.ts
@@ -35,6 +35,12 @@ export type Article = {
url: Scalars['String']['output'];
};
+/** Authentication payload returned from login mutation */
+export type AuthPayload = {
+ /** The authenticated user */
+ user: User;
+};
+
/** Represents a feed subscription in the system */
export type Feed = {
/** Articles belonging to this feed */
@@ -55,6 +61,10 @@ export type Feed = {
export type Mutation = {
/** Add a new feed subscription */
addFeed: Feed;
+ /** Login with username and password. Creates a session cookie. */
+ login: AuthPayload;
+ /** Logout the current user and destroy the session */
+ logout: Scalars['Boolean']['output'];
/** Mark an article as read */
markArticleRead: Article;
/** Mark an article as unread */
@@ -75,6 +85,13 @@ export type MutationAddFeedArgs = {
/** Root mutation type for modifying data */
+export type MutationLoginArgs = {
+ password: Scalars['String']['input'];
+ username: Scalars['String']['input'];
+};
+
+
+/** Root mutation type for modifying data */
export type MutationMarkArticleReadArgs = {
id: Scalars['ID']['input'];
};
@@ -111,6 +128,8 @@ export type Query = {
feed?: Maybe<Feed>;
/** Get all feeds with their metadata */
feeds: Array<Feed>;
+ /** Get the currently authenticated user */
+ me?: Maybe<User>;
/** Get all read articles across all feeds */
readArticles: Array<Article>;
/** Get all unread articles across all feeds */
@@ -129,6 +148,14 @@ export type QueryFeedArgs = {
id: Scalars['ID']['input'];
};
+/** Represents a user in the system */
+export type User = {
+ /** Unique identifier for the user */
+ id: Scalars['ID']['output'];
+ /** Username of the user */
+ username: Scalars['String']['output'];
+};
+
export type AddFeedMutationVariables = Exact<{
url: Scalars['String']['input'];
}>;
@@ -171,6 +198,19 @@ export type MarkFeedUnreadMutationVariables = Exact<{
export type MarkFeedUnreadMutation = { markFeedUnread: { id: string, url: string, title: string, fetchedAt: string } };
+export type LoginMutationVariables = Exact<{
+ username: Scalars['String']['input'];
+ password: Scalars['String']['input'];
+}>;
+
+
+export type LoginMutation = { login: { user: { id: string, username: string } } };
+
+export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
+
+
+export type LogoutMutation = { logout: boolean };
+
export type GetFeedsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -200,6 +240,11 @@ export type GetArticleQueryVariables = Exact<{
export type GetArticleQuery = { article?: { id: string, feedId: string, guid: string, title: string, url: string, isRead: boolean, feed: { id: string, title: string, isSubscribed: boolean } } | null };
+export type GetMeQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type GetMeQuery = { me?: { id: string, username: string } | null };
+
export const AddFeedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddFeed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"url"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addFeed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"url"},"value":{"kind":"Variable","name":{"kind":"Name","value":"url"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}}]}}]}}]} as unknown as DocumentNode<AddFeedMutation, AddFeedMutationVariables>;
export const UnsubscribeFeedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UnsubscribeFeed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unsubscribeFeed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<UnsubscribeFeedMutation, UnsubscribeFeedMutationVariables>;
@@ -207,8 +252,11 @@ export const MarkArticleReadDocument = {"kind":"Document","definitions":[{"kind"
export const MarkArticleUnreadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MarkArticleUnread"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"markArticleUnread"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}}]}}]}}]} as unknown as DocumentNode<MarkArticleUnreadMutation, MarkArticleUnreadMutationVariables>;
export const MarkFeedReadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MarkFeedRead"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"markFeedRead"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}}]}}]}}]} as unknown as DocumentNode<MarkFeedReadMutation, MarkFeedReadMutationVariables>;
export const MarkFeedUnreadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MarkFeedUnread"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"markFeedUnread"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}}]}}]}}]} as unknown as DocumentNode<MarkFeedUnreadMutation, MarkFeedUnreadMutationVariables>;
+export const LoginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Login"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"username"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"username"},"value":{"kind":"Variable","name":{"kind":"Name","value":"username"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]}}]} as unknown as DocumentNode<LoginMutation, LoginMutationVariables>;
+export const LogoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Logout"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logout"}}]}}]} as unknown as DocumentNode<LogoutMutation, LogoutMutationVariables>;
export const GetFeedsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFeeds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"feeds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}},{"kind":"Field","name":{"kind":"Name","value":"articles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}}]}}]}}]}}]} as unknown as DocumentNode<GetFeedsQuery, GetFeedsQueryVariables>;
export const GetUnreadArticlesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUnreadArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unreadArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}}]}}]} as unknown as DocumentNode<GetUnreadArticlesQuery, GetUnreadArticlesQueryVariables>;
export const GetReadArticlesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}}]}}]} as unknown as DocumentNode<GetReadArticlesQuery, GetReadArticlesQueryVariables>;
export const GetFeedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFeed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"feed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}},{"kind":"Field","name":{"kind":"Name","value":"articles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}}]}}]}}]}}]} as unknown as DocumentNode<GetFeedQuery, GetFeedQueryVariables>;
-export const GetArticleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetArticle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"article"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}}]}}]} as unknown as DocumentNode<GetArticleQuery, GetArticleQueryVariables>; \ No newline at end of file
+export const GetArticleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetArticle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"article"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}}]}}]} as unknown as DocumentNode<GetArticleQuery, GetArticleQueryVariables>;
+export const GetMeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMe"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode<GetMeQuery, GetMeQueryVariables>; \ No newline at end of file
diff --git a/frontend/src/graphql/mutations.graphql b/frontend/src/graphql/mutations.graphql
index 9070118..f919e66 100644
--- a/frontend/src/graphql/mutations.graphql
+++ b/frontend/src/graphql/mutations.graphql
@@ -50,3 +50,16 @@ mutation MarkFeedUnread($id: ID!) {
fetchedAt
}
}
+
+mutation Login($username: String!, $password: String!) {
+ login(username: $username, password: $password) {
+ user {
+ id
+ username
+ }
+ }
+}
+
+mutation Logout {
+ logout
+}
diff --git a/frontend/src/graphql/queries.graphql b/frontend/src/graphql/queries.graphql
index 0e96851..5f183a8 100644
--- a/frontend/src/graphql/queries.graphql
+++ b/frontend/src/graphql/queries.graphql
@@ -76,3 +76,10 @@ query GetArticle($id: ID!) {
}
}
}
+
+query GetMe {
+ me {
+ id
+ username
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 34a72b2..d8ac70e 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -3,13 +3,16 @@ import { createRoot } from "react-dom/client";
import { Provider } from "urql";
import "./index.css";
import App from "./App.tsx";
+import { AuthProvider } from "./contexts/AuthContext";
import { client } from "./services/graphql-client";
// biome-ignore lint/style/noNonNullAssertion: root element is guaranteed to exist
createRoot(document.getElementById("root")!).render(
<StrictMode>
- <Provider value={client}>
- <App />
- </Provider>
+ <AuthProvider>
+ <Provider value={client}>
+ <App />
+ </Provider>
+ </AuthProvider>
</StrictMode>,
);
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..5703047
--- /dev/null
+++ b/frontend/src/pages/Login.tsx
@@ -0,0 +1,133 @@
+import { useState } from "react";
+import { useLocation } from "wouter";
+import { useAuth } from "../contexts/AuthContext";
+
+export function Login() {
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const { login } = useAuth();
+ const [, setLocation] = useLocation();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setIsLoading(true);
+
+ try {
+ const success = await login(username, password);
+ if (success) {
+ setLocation("/");
+ } else {
+ setError("Invalid username or password");
+ }
+ } catch (_err) {
+ setError("An error occurred during login");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ minHeight: "100vh",
+ backgroundColor: "#f5f5f5",
+ }}
+ >
+ <div
+ style={{
+ backgroundColor: "white",
+ padding: "2rem",
+ borderRadius: "8px",
+ boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
+ width: "100%",
+ maxWidth: "400px",
+ }}
+ >
+ <h1 style={{ marginBottom: "1.5rem", textAlign: "center" }}>
+ Feedaka Login
+ </h1>
+ <form onSubmit={handleSubmit}>
+ <div style={{ marginBottom: "1rem" }}>
+ <label
+ htmlFor="username"
+ style={{ display: "block", marginBottom: "0.5rem" }}
+ >
+ Username
+ </label>
+ <input
+ id="username"
+ type="text"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ required
+ style={{
+ width: "100%",
+ padding: "0.5rem",
+ border: "1px solid #ccc",
+ borderRadius: "4px",
+ }}
+ disabled={isLoading}
+ />
+ </div>
+ <div style={{ marginBottom: "1rem" }}>
+ <label
+ htmlFor="password"
+ style={{ display: "block", marginBottom: "0.5rem" }}
+ >
+ Password
+ </label>
+ <input
+ id="password"
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ required
+ style={{
+ width: "100%",
+ padding: "0.5rem",
+ border: "1px solid #ccc",
+ borderRadius: "4px",
+ }}
+ disabled={isLoading}
+ />
+ </div>
+ {error && (
+ <div
+ style={{
+ color: "red",
+ marginBottom: "1rem",
+ padding: "0.5rem",
+ backgroundColor: "#fee",
+ borderRadius: "4px",
+ }}
+ >
+ {error}
+ </div>
+ )}
+ <button
+ type="submit"
+ disabled={isLoading}
+ style={{
+ width: "100%",
+ padding: "0.75rem",
+ backgroundColor: "#007bff",
+ color: "white",
+ border: "none",
+ borderRadius: "4px",
+ cursor: isLoading ? "not-allowed" : "pointer",
+ opacity: isLoading ? 0.7 : 1,
+ }}
+ >
+ {isLoading ? "Logging in..." : "Login"}
+ </button>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts
index a037a9d..dd2df8f 100644
--- a/frontend/src/pages/index.ts
+++ b/frontend/src/pages/index.ts
@@ -1,3 +1,4 @@
+export { Login } from "./Login";
export { NotFound } from "./NotFound";
export { ReadArticles } from "./ReadArticles";
export { Settings } from "./Settings";
diff --git a/frontend/src/services/graphql-client.ts b/frontend/src/services/graphql-client.ts
index ad2680a..4b2532a 100644
--- a/frontend/src/services/graphql-client.ts
+++ b/frontend/src/services/graphql-client.ts
@@ -3,4 +3,8 @@ import { Client, cacheExchange, fetchExchange } from "urql";
export const client = new Client({
url: "/graphql",
exchanges: [cacheExchange, fetchExchange],
+ fetchOptions: {
+ // Include cookies for session management
+ credentials: "include",
+ },
});