aboutsummaryrefslogtreecommitdiffhomepage
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
parentba1e0c904f810193f25d4f88cc2bb168f1d625fe (diff)
downloadfeedaka-feat/multi-user.tar.gz
feedaka-feat/multi-user.tar.zst
feedaka-feat/multi-user.zip
-rw-r--r--backend/auth/session.go99
-rw-r--r--backend/cmd_serve.go33
-rw-r--r--backend/context/user.go20
-rw-r--r--backend/db/articles.sql.go23
-rw-r--r--backend/db/feeds.sql.go28
-rw-r--r--backend/db/queries/articles.sql6
-rw-r--r--backend/db/queries/feeds.sql6
-rw-r--r--backend/go.mod12
-rw-r--r--backend/go.sum18
-rw-r--r--backend/graphql/generated.go588
-rw-r--r--backend/graphql/model/generated.go14
-rw-r--r--backend/graphql/resolver/auth_helpers.go35
-rw-r--r--backend/graphql/resolver/resolver.go7
-rw-r--r--backend/graphql/resolver/schema.resolvers.go216
-rw-r--r--backend/middleware.go25
-rw-r--r--common/graphql/schema.graphql40
-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
29 files changed, 1520 insertions, 68 deletions
diff --git a/backend/auth/session.go b/backend/auth/session.go
new file mode 100644
index 0000000..b78de4e
--- /dev/null
+++ b/backend/auth/session.go
@@ -0,0 +1,99 @@
+package auth
+
+import (
+ "errors"
+ "os"
+
+ "github.com/gorilla/sessions"
+ "github.com/labstack/echo-contrib/session"
+ "github.com/labstack/echo/v4"
+)
+
+const (
+ sessionName = "feedaka_session"
+ sessionUserIDKey = "user_id"
+ // Session duration: 7 days
+ sessionMaxAge = 7 * 24 * 60 * 60
+)
+
+var (
+ ErrNoSession = errors.New("no session found")
+ ErrNoUserIDInSession = errors.New("no user_id in session")
+)
+
+type SessionConfig struct {
+ store *sessions.CookieStore
+}
+
+// NewSessionConfig creates a new session configuration.
+// Reads SESSION_SECRET from environment variable.
+// If not set, uses a default value (NOT recommended for production).
+func NewSessionConfig() *SessionConfig {
+ secret := os.Getenv("SESSION_SECRET")
+ if secret == "" {
+ // Default secret for development - CHANGE THIS IN PRODUCTION
+ secret = "feedaka-default-session-secret-change-me-in-production"
+ }
+
+ store := sessions.NewCookieStore([]byte(secret))
+ store.Options = &sessions.Options{
+ Path: "/",
+ MaxAge: sessionMaxAge,
+ HttpOnly: true,
+ Secure: true, // Set to true in production with HTTPS
+ SameSite: 3, // SameSite=Strict
+ }
+
+ return &SessionConfig{
+ store: store,
+ }
+}
+
+// GetStore returns the session store.
+func (c *SessionConfig) GetStore() *sessions.CookieStore {
+ return c.store
+}
+
+// SetUserID stores the user ID in the session.
+func (c *SessionConfig) SetUserID(ctx echo.Context, userID int64) error {
+ sess, err := session.Get(sessionName, ctx)
+ if err != nil {
+ return err
+ }
+
+ sess.Values[sessionUserIDKey] = userID
+ return sess.Save(ctx.Request(), ctx.Response())
+}
+
+// GetUserID retrieves the user ID from the session.
+func (c *SessionConfig) GetUserID(ctx echo.Context) (int64, error) {
+ sess, err := session.Get(sessionName, ctx)
+ if err != nil {
+ return 0, ErrNoSession
+ }
+
+ userIDVal, ok := sess.Values[sessionUserIDKey]
+ if !ok {
+ return 0, ErrNoUserIDInSession
+ }
+
+ userID, ok := userIDVal.(int64)
+ if !ok {
+ return 0, ErrNoUserIDInSession
+ }
+
+ return userID, nil
+}
+
+// DestroySession removes the session.
+func (c *SessionConfig) DestroySession(ctx echo.Context) error {
+ sess, err := session.Get(sessionName, ctx)
+ if err != nil {
+ // If there's no session, nothing to destroy
+ return nil
+ }
+
+ // Set MaxAge to -1 to delete the session
+ sess.Options.MaxAge = -1
+ return sess.Save(ctx.Request(), ctx.Response())
+}
diff --git a/backend/cmd_serve.go b/backend/cmd_serve.go
index 66ceaf9..33de515 100644
--- a/backend/cmd_serve.go
+++ b/backend/cmd_serve.go
@@ -17,11 +17,13 @@ import (
"github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/hashicorp/go-multierror"
+ "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/mmcdole/gofeed"
"github.com/vektah/gqlparser/v2/ast"
+ "undef.ninja/x/feedaka/auth"
"undef.ninja/x/feedaka/db"
"undef.ninja/x/feedaka/graphql"
"undef.ninja/x/feedaka/graphql/resolver"
@@ -149,11 +151,16 @@ func runServe(database *sql.DB) {
queries := db.New(database)
+ // Initialize session config
+ sessionConfig := auth.NewSessionConfig()
+
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORS())
+ // Setup session middleware
+ e.Use(session.Middleware(sessionConfig.GetStore()))
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
@@ -162,7 +169,11 @@ func runServe(database *sql.DB) {
}))
// Setup GraphQL server
- srv := handler.New(graphql.NewExecutableSchema(graphql.Config{Resolvers: &resolver.Resolver{DB: database, Queries: queries}}))
+ srv := handler.New(graphql.NewExecutableSchema(graphql.Config{Resolvers: &resolver.Resolver{
+ DB: database,
+ Queries: queries,
+ SessionConfig: sessionConfig,
+ }}))
srv.AddTransport(transport.Options{})
srv.AddTransport(transport.GET{})
@@ -175,9 +186,23 @@ func runServe(database *sql.DB) {
Cache: lru.New[string](100),
})
- // GraphQL endpoints
- e.POST("/graphql", echo.WrapHandler(srv))
- e.GET("/graphql", echo.WrapHandler(srv))
+ // GraphQL endpoints with authentication middleware
+ graphqlGroup := e.Group("/graphql")
+ graphqlGroup.Use(SessionAuthMiddleware(sessionConfig))
+ graphqlGroup.POST("", func(c echo.Context) error {
+ // Add Echo context to GraphQL context
+ ctx := context.WithValue(c.Request().Context(), "echo", c)
+ req := c.Request().WithContext(ctx)
+ srv.ServeHTTP(c.Response(), req)
+ return nil
+ })
+ graphqlGroup.GET("", func(c echo.Context) error {
+ // Add Echo context to GraphQL context
+ ctx := context.WithValue(c.Request().Context(), "echo", c)
+ req := c.Request().WithContext(ctx)
+ srv.ServeHTTP(c.Response(), req)
+ return nil
+ })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
diff --git a/backend/context/user.go b/backend/context/user.go
new file mode 100644
index 0000000..b8987e4
--- /dev/null
+++ b/backend/context/user.go
@@ -0,0 +1,20 @@
+package context
+
+import (
+ "context"
+)
+
+type contextKey string
+
+const userIDContextKey contextKey = "user_id"
+
+// SetUserID adds the user ID to the context
+func SetUserID(ctx context.Context, userID int64) context.Context {
+ return context.WithValue(ctx, userIDContextKey, userID)
+}
+
+// GetUserID retrieves the user ID from the request context
+func GetUserID(ctx context.Context) (int64, bool) {
+ userID, ok := ctx.Value(userIDContextKey).(int64)
+ return userID, ok
+}
diff --git a/backend/db/articles.sql.go b/backend/db/articles.sql.go
index 7492598..e4419a8 100644
--- a/backend/db/articles.sql.go
+++ b/backend/db/articles.sql.go
@@ -78,9 +78,14 @@ SELECT
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.id = ?
+WHERE a.id = ? AND f.user_id = ?
`
+type GetArticleParams struct {
+ ID int64
+ UserID int64
+}
+
type GetArticleRow struct {
ID int64
FeedID int64
@@ -94,8 +99,8 @@ type GetArticleRow struct {
FeedIsSubscribed int64
}
-func (q *Queries) GetArticle(ctx context.Context, id int64) (GetArticleRow, error) {
- row := q.db.QueryRowContext(ctx, getArticle, id)
+func (q *Queries) GetArticle(ctx context.Context, arg GetArticleParams) (GetArticleRow, error) {
+ row := q.db.QueryRowContext(ctx, getArticle, arg.ID, arg.UserID)
var i GetArticleRow
err := row.Scan(
&i.ID,
@@ -184,7 +189,7 @@ SELECT
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 1 AND f.is_subscribed = 1
+WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ?
ORDER BY a.id DESC
LIMIT 100
`
@@ -202,8 +207,8 @@ type GetReadArticlesRow struct {
FeedIsSubscribed int64
}
-func (q *Queries) GetReadArticles(ctx context.Context) ([]GetReadArticlesRow, error) {
- rows, err := q.db.QueryContext(ctx, getReadArticles)
+func (q *Queries) GetReadArticles(ctx context.Context, userID int64) ([]GetReadArticlesRow, error) {
+ rows, err := q.db.QueryContext(ctx, getReadArticles, userID)
if err != nil {
return nil, err
}
@@ -242,7 +247,7 @@ SELECT
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 0 AND f.is_subscribed = 1
+WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ?
ORDER BY a.id DESC
LIMIT 100
`
@@ -260,8 +265,8 @@ type GetUnreadArticlesRow struct {
FeedIsSubscribed int64
}
-func (q *Queries) GetUnreadArticles(ctx context.Context) ([]GetUnreadArticlesRow, error) {
- rows, err := q.db.QueryContext(ctx, getUnreadArticles)
+func (q *Queries) GetUnreadArticles(ctx context.Context, userID int64) ([]GetUnreadArticlesRow, error) {
+ rows, err := q.db.QueryContext(ctx, getUnreadArticles, userID)
if err != nil {
return nil, err
}
diff --git a/backend/db/feeds.sql.go b/backend/db/feeds.sql.go
index 140fa3a..5a9b6b9 100644
--- a/backend/db/feeds.sql.go
+++ b/backend/db/feeds.sql.go
@@ -54,11 +54,16 @@ func (q *Queries) DeleteFeed(ctx context.Context, id int64) error {
const getFeed = `-- name: GetFeed :one
SELECT id, url, title, fetched_at, is_subscribed, user_id
FROM feeds
-WHERE id = ?
+WHERE id = ? AND user_id = ?
`
-func (q *Queries) GetFeed(ctx context.Context, id int64) (Feed, error) {
- row := q.db.QueryRowContext(ctx, getFeed, id)
+type GetFeedParams struct {
+ ID int64
+ UserID int64
+}
+
+func (q *Queries) GetFeed(ctx context.Context, arg GetFeedParams) (Feed, error) {
+ row := q.db.QueryRowContext(ctx, getFeed, arg.ID, arg.UserID)
var i Feed
err := row.Scan(
&i.ID,
@@ -74,11 +79,16 @@ func (q *Queries) GetFeed(ctx context.Context, id int64) (Feed, error) {
const getFeedByURL = `-- name: GetFeedByURL :one
SELECT id, url, title, fetched_at, is_subscribed, user_id
FROM feeds
-WHERE url = ?
+WHERE url = ? AND user_id = ?
`
-func (q *Queries) GetFeedByURL(ctx context.Context, url string) (Feed, error) {
- row := q.db.QueryRowContext(ctx, getFeedByURL, url)
+type GetFeedByURLParams struct {
+ Url string
+ UserID int64
+}
+
+func (q *Queries) GetFeedByURL(ctx context.Context, arg GetFeedByURLParams) (Feed, error) {
+ row := q.db.QueryRowContext(ctx, getFeedByURL, arg.Url, arg.UserID)
var i Feed
err := row.Scan(
&i.ID,
@@ -94,12 +104,12 @@ func (q *Queries) GetFeedByURL(ctx context.Context, url string) (Feed, error) {
const getFeeds = `-- name: GetFeeds :many
SELECT id, url, title, fetched_at, is_subscribed, user_id
FROM feeds
-WHERE is_subscribed = 1
+WHERE is_subscribed = 1 AND user_id = ?
ORDER BY id
`
-func (q *Queries) GetFeeds(ctx context.Context) ([]Feed, error) {
- rows, err := q.db.QueryContext(ctx, getFeeds)
+func (q *Queries) GetFeeds(ctx context.Context, userID int64) ([]Feed, error) {
+ rows, err := q.db.QueryContext(ctx, getFeeds, userID)
if err != nil {
return nil, err
}
diff --git a/backend/db/queries/articles.sql b/backend/db/queries/articles.sql
index c1feaae..b910fcf 100644
--- a/backend/db/queries/articles.sql
+++ b/backend/db/queries/articles.sql
@@ -4,7 +4,7 @@ SELECT
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.id = ?;
+WHERE a.id = ? AND f.user_id = ?;
-- name: GetUnreadArticles :many
SELECT
@@ -12,7 +12,7 @@ SELECT
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 0 AND f.is_subscribed = 1
+WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ?
ORDER BY a.id DESC
LIMIT 100;
@@ -22,7 +22,7 @@ SELECT
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 1 AND f.is_subscribed = 1
+WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ?
ORDER BY a.id DESC
LIMIT 100;
diff --git a/backend/db/queries/feeds.sql b/backend/db/queries/feeds.sql
index 9725252..9b0b8f7 100644
--- a/backend/db/queries/feeds.sql
+++ b/backend/db/queries/feeds.sql
@@ -1,12 +1,12 @@
-- name: GetFeed :one
SELECT id, url, title, fetched_at, is_subscribed, user_id
FROM feeds
-WHERE id = ?;
+WHERE id = ? AND user_id = ?;
-- name: GetFeeds :many
SELECT id, url, title, fetched_at, is_subscribed, user_id
FROM feeds
-WHERE is_subscribed = 1
+WHERE is_subscribed = 1 AND user_id = ?
ORDER BY id;
-- name: CreateFeed :one
@@ -26,7 +26,7 @@ WHERE id = ?;
-- name: GetFeedByURL :one
SELECT id, url, title, fetched_at, is_subscribed, user_id
FROM feeds
-WHERE url = ?;
+WHERE url = ? AND user_id = ?;
-- name: GetFeedsToFetch :many
SELECT id, url, fetched_at, user_id
diff --git a/backend/go.mod b/backend/go.mod
index 68a6343..cdff2bd 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -6,6 +6,7 @@ toolchain go1.24.4
require (
github.com/99designs/gqlgen v0.17.76
+ github.com/gorilla/sessions v1.4.0
github.com/hashicorp/go-multierror v1.1.1
github.com/labstack/echo/v4 v4.13.4
github.com/mattn/go-sqlite3 v1.14.28
@@ -15,7 +16,7 @@ require (
)
require (
- cel.dev/expr v0.19.1 // indirect
+ cel.dev/expr v0.20.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
@@ -30,6 +31,8 @@ require (
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/google/cel-go v0.24.1 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/context v1.1.2 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -40,6 +43,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+ github.com/labstack/echo-contrib v0.17.4 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -78,9 +82,9 @@ require (
golang.org/x/text v0.26.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.34.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
- google.golang.org/grpc v1.71.1 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
+ google.golang.org/grpc v1.72.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 2d128a0..295d7c9 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,5 +1,7 @@
cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
+cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI=
+cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/99designs/gqlgen v0.17.76 h1:YsJBcfACWmXWU2t1yCjoGdOmqcTfOFpjbLAE443fmYI=
@@ -50,10 +52,18 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
+github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -84,6 +94,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
+github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -280,10 +292,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
+google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
+google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
diff --git a/backend/graphql/generated.go b/backend/graphql/generated.go
index e4790bd..479e590 100644
--- a/backend/graphql/generated.go
+++ b/backend/graphql/generated.go
@@ -56,6 +56,10 @@ type ComplexityRoot struct {
URL func(childComplexity int) int
}
+ AuthPayload struct {
+ User func(childComplexity int) int
+ }
+
Feed struct {
Articles func(childComplexity int) int
FetchedAt func(childComplexity int) int
@@ -67,6 +71,8 @@ type ComplexityRoot struct {
Mutation struct {
AddFeed func(childComplexity int, url string) int
+ Login func(childComplexity int, username string, password string) int
+ Logout func(childComplexity int) int
MarkArticleRead func(childComplexity int, id string) int
MarkArticleUnread func(childComplexity int, id string) int
MarkFeedRead func(childComplexity int, id string) int
@@ -78,9 +84,15 @@ type ComplexityRoot struct {
Article func(childComplexity int, id string) int
Feed func(childComplexity int, id string) int
Feeds func(childComplexity int) int
+ Me func(childComplexity int) int
ReadArticles func(childComplexity int) int
UnreadArticles func(childComplexity int) int
}
+
+ User struct {
+ ID func(childComplexity int) int
+ Username func(childComplexity int) int
+ }
}
type MutationResolver interface {
@@ -90,6 +102,8 @@ type MutationResolver interface {
MarkArticleUnread(ctx context.Context, id string) (*model.Article, error)
MarkFeedRead(ctx context.Context, id string) (*model.Feed, error)
MarkFeedUnread(ctx context.Context, id string) (*model.Feed, error)
+ Login(ctx context.Context, username string, password string) (*model.AuthPayload, error)
+ Logout(ctx context.Context) (bool, error)
}
type QueryResolver interface {
Feeds(ctx context.Context) ([]*model.Feed, error)
@@ -97,6 +111,7 @@ type QueryResolver interface {
ReadArticles(ctx context.Context) ([]*model.Article, error)
Feed(ctx context.Context, id string) (*model.Feed, error)
Article(ctx context.Context, id string) (*model.Article, error)
+ Me(ctx context.Context) (*model.User, error)
}
type executableSchema struct {
@@ -167,6 +182,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Article.URL(childComplexity), true
+ case "AuthPayload.user":
+ if e.complexity.AuthPayload.User == nil {
+ break
+ }
+
+ return e.complexity.AuthPayload.User(childComplexity), true
+
case "Feed.articles":
if e.complexity.Feed.Articles == nil {
break
@@ -221,6 +243,25 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Mutation.AddFeed(childComplexity, args["url"].(string)), true
+ case "Mutation.login":
+ if e.complexity.Mutation.Login == nil {
+ break
+ }
+
+ args, err := ec.field_Mutation_login_args(ctx, rawArgs)
+ if err != nil {
+ return 0, false
+ }
+
+ return e.complexity.Mutation.Login(childComplexity, args["username"].(string), args["password"].(string)), true
+
+ case "Mutation.logout":
+ if e.complexity.Mutation.Logout == nil {
+ break
+ }
+
+ return e.complexity.Mutation.Logout(childComplexity), true
+
case "Mutation.markArticleRead":
if e.complexity.Mutation.MarkArticleRead == nil {
break
@@ -312,6 +353,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Query.Feeds(childComplexity), true
+ case "Query.me":
+ if e.complexity.Query.Me == nil {
+ break
+ }
+
+ return e.complexity.Query.Me(childComplexity), true
+
case "Query.readArticles":
if e.complexity.Query.ReadArticles == nil {
break
@@ -326,6 +374,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Query.UnreadArticles(childComplexity), true
+ case "User.id":
+ if e.complexity.User.ID == nil {
+ break
+ }
+
+ return e.complexity.User.ID(childComplexity), true
+
+ case "User.username":
+ if e.complexity.User.Username == nil {
+ break
+ }
+
+ return e.complexity.User.Username(childComplexity), true
+
}
return 0, false
}
@@ -508,6 +570,31 @@ type Article {
}
"""
+Represents a user in the system
+"""
+type User {
+ """
+ Unique identifier for the user
+ """
+ id: ID!
+
+ """
+ Username of the user
+ """
+ username: String!
+}
+
+"""
+Authentication payload returned from login mutation
+"""
+type AuthPayload {
+ """
+ The authenticated user
+ """
+ user: User!
+}
+
+"""
Root query type for reading data
"""
type Query {
@@ -535,6 +622,11 @@ type Query {
Get a specific article by ID
"""
article(id: ID!): Article
+
+ """
+ Get the currently authenticated user
+ """
+ me: User
}
"""
@@ -570,6 +662,16 @@ type Mutation {
Mark all articles in a feed as unread
"""
markFeedUnread(id: ID!): Feed!
+
+ """
+ Login with username and password. Creates a session cookie.
+ """
+ login(username: String!, password: String!): AuthPayload!
+
+ """
+ Logout the current user and destroy the session
+ """
+ logout: Boolean!
}
`, BuiltIn: false},
}
@@ -607,6 +709,57 @@ func (ec *executionContext) field_Mutation_addFeed_argsURL(
return zeroVal, nil
}
+func (ec *executionContext) field_Mutation_login_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+ var err error
+ args := map[string]any{}
+ arg0, err := ec.field_Mutation_login_argsUsername(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["username"] = arg0
+ arg1, err := ec.field_Mutation_login_argsPassword(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["password"] = arg1
+ return args, nil
+}
+func (ec *executionContext) field_Mutation_login_argsUsername(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (string, error) {
+ if _, ok := rawArgs["username"]; !ok {
+ var zeroVal string
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("username"))
+ if tmp, ok := rawArgs["username"]; ok {
+ return ec.unmarshalNString2string(ctx, tmp)
+ }
+
+ var zeroVal string
+ return zeroVal, nil
+}
+
+func (ec *executionContext) field_Mutation_login_argsPassword(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (string, error) {
+ if _, ok := rawArgs["password"]; !ok {
+ var zeroVal string
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("password"))
+ if tmp, ok := rawArgs["password"]; ok {
+ return ec.unmarshalNString2string(ctx, tmp)
+ }
+
+ var zeroVal string
+ return zeroVal, nil
+}
+
func (ec *executionContext) field_Mutation_markArticleRead_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -1273,6 +1426,56 @@ func (ec *executionContext) fieldContext_Article_feed(_ context.Context, field g
return fc, nil
}
+func (ec *executionContext) _AuthPayload_user(ctx context.Context, field graphql.CollectedField, obj *model.AuthPayload) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_AuthPayload_user(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.User, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(*model.User)
+ fc.Result = res
+ return ec.marshalNUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_AuthPayload_user(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "AuthPayload",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "id":
+ return ec.fieldContext_User_id(ctx, field)
+ case "username":
+ return ec.fieldContext_User_username(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _Feed_id(ctx context.Context, field graphql.CollectedField, obj *model.Feed) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Feed_id(ctx, field)
if err != nil {
@@ -1957,6 +2160,109 @@ func (ec *executionContext) fieldContext_Mutation_markFeedUnread(ctx context.Con
return fc, nil
}
+func (ec *executionContext) _Mutation_login(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_Mutation_login(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Mutation().Login(rctx, fc.Args["username"].(string), fc.Args["password"].(string))
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(*model.AuthPayload)
+ fc.Result = res
+ return ec.marshalNAuthPayload2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Mutation_login(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "Mutation",
+ Field: field,
+ IsMethod: true,
+ IsResolver: true,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "user":
+ return ec.fieldContext_AuthPayload_user(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type AuthPayload", field.Name)
+ },
+ }
+ defer func() {
+ if r := recover(); r != nil {
+ err = ec.Recover(ctx, r)
+ ec.Error(ctx, err)
+ }
+ }()
+ ctx = graphql.WithFieldContext(ctx, fc)
+ if fc.Args, err = ec.field_Mutation_login_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+ ec.Error(ctx, err)
+ return fc, err
+ }
+ return fc, nil
+}
+
+func (ec *executionContext) _Mutation_logout(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_Mutation_logout(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Mutation().Logout(rctx)
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(bool)
+ fc.Result = res
+ return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Mutation_logout(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "Mutation",
+ Field: field,
+ IsMethod: true,
+ IsResolver: true,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ return nil, errors.New("field of type Boolean does not have child fields")
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _Query_feeds(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Query_feeds(ctx, field)
if err != nil {
@@ -2269,6 +2575,53 @@ func (ec *executionContext) fieldContext_Query_article(ctx context.Context, fiel
return fc, nil
}
+func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_Query_me(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Query().Me(rctx)
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ return graphql.Null
+ }
+ res := resTmp.(*model.User)
+ fc.Result = res
+ return ec.marshalOUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Query_me(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "Query",
+ Field: field,
+ IsMethod: true,
+ IsResolver: true,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "id":
+ return ec.fieldContext_User_id(ctx, field)
+ case "username":
+ return ec.fieldContext_User_username(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Query___type(ctx, field)
if err != nil {
@@ -2400,6 +2753,94 @@ func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field
return fc, nil
}
+func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_User_id(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.ID, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(string)
+ fc.Result = res
+ return ec.marshalNID2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_User_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "User",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ return nil, errors.New("field of type ID does not have child fields")
+ },
+ }
+ return fc, nil
+}
+
+func (ec *executionContext) _User_username(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_User_username(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Username, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(string)
+ fc.Result = res
+ return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_User_username(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "User",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ return nil, errors.New("field of type String does not have child fields")
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) {
fc, err := ec.fieldContext___Directive_name(ctx, field)
if err != nil {
@@ -4428,6 +4869,45 @@ func (ec *executionContext) _Article(ctx context.Context, sel ast.SelectionSet,
return out
}
+var authPayloadImplementors = []string{"AuthPayload"}
+
+func (ec *executionContext) _AuthPayload(ctx context.Context, sel ast.SelectionSet, obj *model.AuthPayload) graphql.Marshaler {
+ fields := graphql.CollectFields(ec.OperationContext, sel, authPayloadImplementors)
+
+ out := graphql.NewFieldSet(fields)
+ deferred := make(map[string]*graphql.FieldSet)
+ for i, field := range fields {
+ switch field.Name {
+ case "__typename":
+ out.Values[i] = graphql.MarshalString("AuthPayload")
+ case "user":
+ out.Values[i] = ec._AuthPayload_user(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ default:
+ panic("unknown field " + strconv.Quote(field.Name))
+ }
+ }
+ out.Dispatch(ctx)
+ if out.Invalids > 0 {
+ return graphql.Null
+ }
+
+ atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+ for label, dfs := range deferred {
+ ec.processDeferredGroup(graphql.DeferredGroup{
+ Label: label,
+ Path: graphql.GetPath(ctx),
+ FieldSet: dfs,
+ Context: ctx,
+ })
+ }
+
+ return out
+}
+
var feedImplementors = []string{"Feed"}
func (ec *executionContext) _Feed(ctx context.Context, sel ast.SelectionSet, obj *model.Feed) graphql.Marshaler {
@@ -4553,6 +5033,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
out.Invalids++
}
+ case "login":
+ out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
+ return ec._Mutation_login(ctx, field)
+ })
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ case "logout":
+ out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
+ return ec._Mutation_logout(ctx, field)
+ })
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@@ -4699,6 +5193,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
+ case "me":
+ field := field
+
+ innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ }
+ }()
+ res = ec._Query_me(ctx, field)
+ return res
+ }
+
+ rrm := func(ctx context.Context) graphql.Marshaler {
+ return ec.OperationContext.RootResolverMiddleware(ctx,
+ func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+ }
+
+ out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "__type":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Query___type(ctx, field)
@@ -4730,6 +5243,50 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
return out
}
+var userImplementors = []string{"User"}
+
+func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *model.User) graphql.Marshaler {
+ fields := graphql.CollectFields(ec.OperationContext, sel, userImplementors)
+
+ out := graphql.NewFieldSet(fields)
+ deferred := make(map[string]*graphql.FieldSet)
+ for i, field := range fields {
+ switch field.Name {
+ case "__typename":
+ out.Values[i] = graphql.MarshalString("User")
+ case "id":
+ out.Values[i] = ec._User_id(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ case "username":
+ out.Values[i] = ec._User_username(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ default:
+ panic("unknown field " + strconv.Quote(field.Name))
+ }
+ }
+ out.Dispatch(ctx)
+ if out.Invalids > 0 {
+ return graphql.Null
+ }
+
+ atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+ for label, dfs := range deferred {
+ ec.processDeferredGroup(graphql.DeferredGroup{
+ Label: label,
+ Path: graphql.GetPath(ctx),
+ FieldSet: dfs,
+ Context: ctx,
+ })
+ }
+
+ return out
+}
+
var __DirectiveImplementors = []string{"__Directive"}
func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler {
@@ -5123,6 +5680,20 @@ func (ec *executionContext) marshalNArticle2ᚖundefᚗninjaᚋxᚋfeedakaᚋgra
return ec._Article(ctx, sel, v)
}
+func (ec *executionContext) marshalNAuthPayload2undefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx context.Context, sel ast.SelectionSet, v model.AuthPayload) graphql.Marshaler {
+ return ec._AuthPayload(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNAuthPayload2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx context.Context, sel ast.SelectionSet, v *model.AuthPayload) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+ }
+ return graphql.Null
+ }
+ return ec._AuthPayload(ctx, sel, v)
+}
+
func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) {
res, err := graphql.UnmarshalBoolean(v)
return res, graphql.ErrorOnPath(ctx, err)
@@ -5245,6 +5816,16 @@ func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.S
return res
}
+func (ec *executionContext) marshalNUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx context.Context, sel ast.SelectionSet, v *model.User) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+ }
+ return graphql.Null
+ }
+ return ec._User(ctx, sel, v)
+}
+
func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler {
return ec.___Directive(ctx, sel, &v)
}
@@ -5560,6 +6141,13 @@ func (ec *executionContext) marshalOString2ᚖstring(ctx context.Context, sel as
return res
}
+func (ec *executionContext) marshalOUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx context.Context, sel ast.SelectionSet, v *model.User) graphql.Marshaler {
+ if v == nil {
+ return graphql.Null
+ }
+ return ec._User(ctx, sel, v)
+}
+
func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler {
if v == nil {
return graphql.Null
diff --git a/backend/graphql/model/generated.go b/backend/graphql/model/generated.go
index 25ed8d8..11f9692 100644
--- a/backend/graphql/model/generated.go
+++ b/backend/graphql/model/generated.go
@@ -20,6 +20,12 @@ type Article struct {
Feed *Feed `json:"feed"`
}
+// Authentication payload returned from login mutation
+type AuthPayload struct {
+ // The authenticated user
+ User *User `json:"user"`
+}
+
// Represents a feed subscription in the system
type Feed struct {
// Unique identifier for the feed
@@ -43,3 +49,11 @@ type Mutation struct {
// Root query type for reading data
type Query struct {
}
+
+// Represents a user in the system
+type User struct {
+ // Unique identifier for the user
+ ID string `json:"id"`
+ // Username of the user
+ Username string `json:"username"`
+}
diff --git a/backend/graphql/resolver/auth_helpers.go b/backend/graphql/resolver/auth_helpers.go
new file mode 100644
index 0000000..433e9e9
--- /dev/null
+++ b/backend/graphql/resolver/auth_helpers.go
@@ -0,0 +1,35 @@
+package resolver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/labstack/echo/v4"
+ "golang.org/x/crypto/bcrypt"
+ appcontext "undef.ninja/x/feedaka/context"
+)
+
+// getUserIDFromContext retrieves the authenticated user ID from context
+// This is a wrapper around the GetUserID function from the context package
+func getUserIDFromContext(ctx context.Context) (int64, error) {
+ userID, ok := appcontext.GetUserID(ctx)
+ if !ok {
+ return 0, fmt.Errorf("authentication required")
+ }
+ return userID, nil
+}
+
+// Helper function to get Echo context from GraphQL context
+func getEchoContext(ctx context.Context) (echo.Context, error) {
+ echoCtx, ok := ctx.Value("echo").(echo.Context)
+ if !ok {
+ return nil, errors.New("echo context not found")
+ }
+ return echoCtx, nil
+}
+
+func verifyPassword(hashedPassword, password string) bool {
+ err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
+ return err == nil
+}
diff --git a/backend/graphql/resolver/resolver.go b/backend/graphql/resolver/resolver.go
index 7a9c389..dea85a0 100644
--- a/backend/graphql/resolver/resolver.go
+++ b/backend/graphql/resolver/resolver.go
@@ -2,6 +2,8 @@ package resolver
import (
"database/sql"
+
+ "undef.ninja/x/feedaka/auth"
"undef.ninja/x/feedaka/db"
)
@@ -10,6 +12,7 @@ import (
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
- DB *sql.DB
- Queries *db.Queries
+ DB *sql.DB
+ Queries *db.Queries
+ SessionConfig *auth.SessionConfig
}
diff --git a/backend/graphql/resolver/schema.resolvers.go b/backend/graphql/resolver/schema.resolvers.go
index 0c811c2..2caa721 100644
--- a/backend/graphql/resolver/schema.resolvers.go
+++ b/backend/graphql/resolver/schema.resolvers.go
@@ -19,6 +19,11 @@ import (
// AddFeed is the resolver for the addFeed field.
func (r *mutationResolver) AddFeed(ctx context.Context, url string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
// Fetch the feed to get its title
fp := gofeed.NewParser()
feed, err := fp.ParseURL(url)
@@ -31,7 +36,7 @@ func (r *mutationResolver) AddFeed(ctx context.Context, url string) (*model.Feed
Url: url,
Title: feed.Title,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
- UserID: int64(1), // TODO
+ UserID: userID,
})
if err != nil {
return nil, fmt.Errorf("failed to insert feed: %w", err)
@@ -63,12 +68,29 @@ func (r *mutationResolver) AddFeed(ctx context.Context, url string) (*model.Feed
// UnsubscribeFeed is the resolver for the unsubscribeFeed field.
func (r *mutationResolver) UnsubscribeFeed(ctx context.Context, id string) (bool, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return false, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return false, fmt.Errorf("invalid feed ID: %w", err)
}
- err = r.Queries.UnsubscribeFeed(ctx, feedID)
+ // Check if feed exists and belongs to user
+ feed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return false, fmt.Errorf("feed not found or access denied")
+ }
+ return false, fmt.Errorf("failed to query feed: %w", err)
+ }
+
+ err = r.Queries.UnsubscribeFeed(ctx, feed.ID)
if err != nil {
return false, fmt.Errorf("failed to unsubscribe from feed: %w", err)
}
@@ -78,15 +100,32 @@ func (r *mutationResolver) UnsubscribeFeed(ctx context.Context, id string) (bool
// MarkArticleRead is the resolver for the markArticleRead field.
func (r *mutationResolver) MarkArticleRead(ctx context.Context, id string) (*model.Article, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
articleID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid article ID: %w", err)
}
+ // Check if article exists and belongs to user
+ article, err := r.Queries.GetArticle(ctx, db.GetArticleParams{
+ ID: articleID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("article not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query article: %w", err)
+ }
+
// Update the article's read status
err = r.Queries.UpdateArticleReadStatus(ctx, db.UpdateArticleReadStatusParams{
IsRead: 1,
- ID: articleID,
+ ID: article.ID,
})
if err != nil {
return nil, fmt.Errorf("failed to mark article as read: %w", err)
@@ -98,15 +137,32 @@ func (r *mutationResolver) MarkArticleRead(ctx context.Context, id string) (*mod
// MarkArticleUnread is the resolver for the markArticleUnread field.
func (r *mutationResolver) MarkArticleUnread(ctx context.Context, id string) (*model.Article, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
articleID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid article ID: %w", err)
}
+ // Check if article exists and belongs to user
+ article, err := r.Queries.GetArticle(ctx, db.GetArticleParams{
+ ID: articleID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("article not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query article: %w", err)
+ }
+
// Update the article's read status
err = r.Queries.UpdateArticleReadStatus(ctx, db.UpdateArticleReadStatusParams{
IsRead: 0,
- ID: articleID,
+ ID: article.ID,
})
if err != nil {
return nil, fmt.Errorf("failed to mark article as unread: %w", err)
@@ -118,13 +174,30 @@ func (r *mutationResolver) MarkArticleUnread(ctx context.Context, id string) (*m
// MarkFeedRead is the resolver for the markFeedRead field.
func (r *mutationResolver) MarkFeedRead(ctx context.Context, id string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid feed ID: %w", err)
}
+ // Check if feed exists and belongs to user
+ feed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("feed not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query feed: %w", err)
+ }
+
// Update all articles in the feed to be read
- err = r.Queries.MarkFeedArticlesRead(ctx, feedID)
+ err = r.Queries.MarkFeedArticlesRead(ctx, feed.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark feed as read: %w", err)
}
@@ -135,13 +208,30 @@ func (r *mutationResolver) MarkFeedRead(ctx context.Context, id string) (*model.
// MarkFeedUnread is the resolver for the markFeedUnread field.
func (r *mutationResolver) MarkFeedUnread(ctx context.Context, id string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid feed ID: %w", err)
}
+ // Check if feed exists and belongs to user
+ feed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("feed not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query feed: %w", err)
+ }
+
// Update all articles in the feed to be unread
- err = r.Queries.MarkFeedArticlesUnread(ctx, feedID)
+ err = r.Queries.MarkFeedArticlesUnread(ctx, feed.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark feed as unread: %w", err)
}
@@ -150,9 +240,65 @@ func (r *mutationResolver) MarkFeedUnread(ctx context.Context, id string) (*mode
return r.Query().Feed(ctx, id)
}
+// Login is the resolver for the login field.
+func (r *mutationResolver) Login(ctx context.Context, username string, password string) (*model.AuthPayload, error) {
+ // Verify user credentials
+ user, err := r.Queries.GetUserByUsername(ctx, username)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("invalid credentials")
+ }
+ return nil, fmt.Errorf("failed to query user: %w", err)
+ }
+
+ // Verify password
+ if !verifyPassword(user.PasswordHash, password) {
+ return nil, fmt.Errorf("invalid credentials")
+ }
+
+ // Get Echo context to create session
+ echoCtx, err := getEchoContext(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get echo context: %w", err)
+ }
+
+ // Create session and store user ID
+ if err := r.SessionConfig.SetUserID(echoCtx, user.ID); err != nil {
+ return nil, fmt.Errorf("failed to create session: %w", err)
+ }
+
+ return &model.AuthPayload{
+ User: &model.User{
+ ID: strconv.FormatInt(user.ID, 10),
+ Username: user.Username,
+ },
+ }, nil
+}
+
+// Logout is the resolver for the logout field.
+func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
+ // Get Echo context to destroy session
+ echoCtx, err := getEchoContext(ctx)
+ if err != nil {
+ return false, fmt.Errorf("failed to get echo context: %w", err)
+ }
+
+ // Destroy session
+ if err := r.SessionConfig.DestroySession(echoCtx); err != nil {
+ return false, fmt.Errorf("failed to destroy session: %w", err)
+ }
+
+ return true, nil
+}
+
// Feeds is the resolver for the feeds field.
func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) {
- dbFeeds, err := r.Queries.GetFeeds(ctx)
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ dbFeeds, err := r.Queries.GetFeeds(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to query feeds: %w", err)
}
@@ -173,7 +319,12 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) {
// UnreadArticles is the resolver for the unreadArticles field.
func (r *queryResolver) UnreadArticles(ctx context.Context) ([]*model.Article, error) {
- rows, err := r.Queries.GetUnreadArticles(ctx)
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := r.Queries.GetUnreadArticles(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to query unread articles: %w", err)
}
@@ -201,7 +352,12 @@ func (r *queryResolver) UnreadArticles(ctx context.Context) ([]*model.Article, e
// ReadArticles is the resolver for the readArticles field.
func (r *queryResolver) ReadArticles(ctx context.Context) ([]*model.Article, error) {
- rows, err := r.Queries.GetReadArticles(ctx)
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := r.Queries.GetReadArticles(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to query read articles: %w", err)
}
@@ -229,12 +385,20 @@ func (r *queryResolver) ReadArticles(ctx context.Context) ([]*model.Article, err
// Feed is the resolver for the feed field.
func (r *queryResolver) Feed(ctx context.Context, id string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid feed ID: %w", err)
}
- dbFeed, err := r.Queries.GetFeed(ctx, feedID)
+ dbFeed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("feed not found")
@@ -253,12 +417,20 @@ func (r *queryResolver) Feed(ctx context.Context, id string) (*model.Feed, error
// Article is the resolver for the article field.
func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
articleID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid article ID: %w", err)
}
- row, err := r.Queries.GetArticle(ctx, articleID)
+ row, err := r.Queries.GetArticle(ctx, db.GetArticleParams{
+ ID: articleID,
+ UserID: userID,
+ })
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("article not found")
@@ -281,6 +453,28 @@ func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article,
}, nil
}
+// Me is the resolver for the me field.
+func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ // Not authenticated - return nil (not an error)
+ return nil, nil
+ }
+
+ user, err := r.Queries.GetUserByID(ctx, userID)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to query user: %w", err)
+ }
+
+ return &model.User{
+ ID: strconv.FormatInt(user.ID, 10),
+ Username: user.Username,
+ }, nil
+}
+
// Mutation returns gql.MutationResolver implementation.
func (r *Resolver) Mutation() gql.MutationResolver { return &mutationResolver{r} }
diff --git a/backend/middleware.go b/backend/middleware.go
new file mode 100644
index 0000000..13234df
--- /dev/null
+++ b/backend/middleware.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+ "github.com/labstack/echo/v4"
+ "undef.ninja/x/feedaka/auth"
+ appcontext "undef.ninja/x/feedaka/context"
+)
+
+// SessionAuthMiddleware validates session and adds user info to context
+func SessionAuthMiddleware(sessionConfig *auth.SessionConfig) echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ // Try to get user ID from session
+ userID, err := sessionConfig.GetUserID(c)
+ if err == nil {
+ // Add user ID to context
+ ctx := appcontext.SetUserID(c.Request().Context(), userID)
+ c.SetRequest(c.Request().WithContext(ctx))
+ }
+ // If no valid session, continue without authentication
+
+ return next(c)
+ }
+ }
+}
diff --git a/common/graphql/schema.graphql b/common/graphql/schema.graphql
index a0bfa7e..55b10b7 100644
--- a/common/graphql/schema.graphql
+++ b/common/graphql/schema.graphql
@@ -76,6 +76,31 @@ type Article {
}
"""
+Represents a user in the system
+"""
+type User {
+ """
+ Unique identifier for the user
+ """
+ id: ID!
+
+ """
+ Username of the user
+ """
+ username: String!
+}
+
+"""
+Authentication payload returned from login mutation
+"""
+type AuthPayload {
+ """
+ The authenticated user
+ """
+ user: User!
+}
+
+"""
Root query type for reading data
"""
type Query {
@@ -103,6 +128,11 @@ type Query {
Get a specific article by ID
"""
article(id: ID!): Article
+
+ """
+ Get the currently authenticated user
+ """
+ me: User
}
"""
@@ -138,4 +168,14 @@ type Mutation {
Mark all articles in a feed as unread
"""
markFeedUnread(id: ID!): Feed!
+
+ """
+ Login with username and password. Creates a session cookie.
+ """
+ login(username: String!, password: String!): AuthPayload!
+
+ """
+ Logout the current user and destroy the session
+ """
+ logout: Boolean!
}
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",
+ },
});