diff options
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", + }, }); |
