diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-11-02 00:00:35 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-11-02 00:00:35 +0900 |
| commit | 104341ddc4add57f83c58cb3fabb23b6fbfdd3e4 (patch) | |
| tree | 862b109fe257e6170a88929729dae3bddfb6eb49 /frontend | |
| parent | ba1e0c904f810193f25d4f88cc2bb168f1d625fe (diff) | |
| download | feedaka-104341ddc4add57f83c58cb3fabb23b6fbfdd3e4.tar.gz feedaka-104341ddc4add57f83c58cb3fabb23b6fbfdd3e4.tar.zst feedaka-104341ddc4add57f83c58cb3fabb23b6fbfdd3e4.zip | |
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/App.tsx | 35 | ||||
| -rw-r--r-- | frontend/src/components/Navigation.tsx | 26 | ||||
| -rw-r--r-- | frontend/src/components/ProtectedRoute.tsx | 32 | ||||
| -rw-r--r-- | frontend/src/components/index.ts | 1 | ||||
| -rw-r--r-- | frontend/src/contexts/AuthContext.tsx | 95 | ||||
| -rw-r--r-- | frontend/src/graphql/generated/gql.ts | 12 | ||||
| -rw-r--r-- | frontend/src/graphql/generated/graphql.ts | 50 | ||||
| -rw-r--r-- | frontend/src/graphql/mutations.graphql | 13 | ||||
| -rw-r--r-- | frontend/src/graphql/queries.graphql | 7 | ||||
| -rw-r--r-- | frontend/src/main.tsx | 9 | ||||
| -rw-r--r-- | frontend/src/pages/Login.tsx | 133 | ||||
| -rw-r--r-- | frontend/src/pages/index.ts | 1 | ||||
| -rw-r--r-- | frontend/src/services/graphql-client.ts | 4 |
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", + }, }); |
