From c517ebaffa470ee559dd643ab16219a11effad65 Mon Sep 17 00:00:00 2001 From: Mathias Magnusson Date: Sun, 14 Jul 2024 00:51:36 +0200 Subject: [PATCH] Add invites --- README.md | 5 + islands/membershipUpload.island.tsx | 4 +- islands/oidcClients.island.tsx | 2 +- pkg/database/invite.sql.go | 113 ++++ .../migrations/20240713201626_add_invites.sql | 16 + pkg/database/models.go | 9 + pkg/database/sql/invite.sql | 23 + pkg/httputil/httputil.go | 67 +-- services/admin/admin.go | 11 +- services/admin/admin.templ | 139 ++++- services/admin/admin_templ.go | 504 +++++++++++++++++- services/admin/handlers.go | 55 +- services/oidcrp/handlers.go | 17 +- services/static/public/clipboard.svg | 4 + services/static/public/delta.svg | 6 + services/static/static.go | 8 + services/user/export/user.go | 1 + services/user/handlers.go | 87 +++ services/user/user.go | 1 + services/user/user.templ | 21 + services/user/user_templ.go | 44 ++ todo.md | 9 + 22 files changed, 1076 insertions(+), 70 deletions(-) create mode 100644 pkg/database/invite.sql.go create mode 100644 pkg/database/migrations/20240713201626_add_invites.sql create mode 100644 pkg/database/sql/invite.sql create mode 100644 services/static/public/clipboard.svg create mode 100644 services/static/public/delta.svg create mode 100644 todo.md diff --git a/README.md b/README.md index d0924f9..e7b9037 100644 --- a/README.md +++ b/README.md @@ -153,3 +153,8 @@ to `/oidc/kth/callback` that redirects the user further within this system and then cookies must be sent, but from some local testing it seems they're not since the user was (indirectly) redirected from KTH. Therefore they're set to `Lax`. + +## Database schema + +The schema is defined by the migrations in `./pkg/database/migrations/`. A new +one can be created using `go run ./cmd/manage goose create $NAME sql`. diff --git a/islands/membershipUpload.island.tsx b/islands/membershipUpload.island.tsx index 46e44e9..569c600 100644 --- a/islands/membershipUpload.island.tsx +++ b/islands/membershipUpload.island.tsx @@ -11,7 +11,7 @@ function MembershipUpload() { setMessages([]); - let res = await fetch("/admin/upload-sheet", { + let res = await fetch("/admin/members/upload-sheet", { method: "post", headers: { "Content-Type": "application/octet-stream; charset=binary" }, body: fileInput.files?.[0], @@ -20,7 +20,7 @@ function MembershipUpload() { setMessages([{ msg: await res.text(), error: true }]); return } - let events = new EventSource("/admin/upload-sheet"); + let events = new EventSource("/admin/members/upload-sheet"); events.addEventListener("error", () => { events.close(); setProgress(null); diff --git a/islands/oidcClients.island.tsx b/islands/oidcClients.island.tsx index 7b91681..5166b46 100644 --- a/islands/oidcClients.island.tsx +++ b/islands/oidcClients.island.tsx @@ -17,7 +17,7 @@ function OidcClients() { let [secret, setSecret] = createSignal(null); onMount(async () => { - let res = await fetch("/admin/oidc-clients"); + let res = await fetch("/admin/list-oidc-clients"); if (res.status != 200) { setError(await res.text()); return; diff --git a/pkg/database/invite.sql.go b/pkg/database/invite.sql.go new file mode 100644 index 0000000..123509d --- /dev/null +++ b/pkg/database/invite.sql.go @@ -0,0 +1,113 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: invite.sql + +package database + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createInvite = `-- name: CreateInvite :one +insert into invites ( + name, + expires_at, + max_uses +) +values ($1, $2, $3) +returning id, name, created_at, expires_at, max_uses, current_uses +` + +type CreateInviteParams struct { + Name string + ExpiresAt pgtype.Timestamp + MaxUses pgtype.Int4 +} + +func (q *Queries) CreateInvite(ctx context.Context, arg CreateInviteParams) (Invite, error) { + row := q.db.QueryRow(ctx, createInvite, arg.Name, arg.ExpiresAt, arg.MaxUses) + var i Invite + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.ExpiresAt, + &i.MaxUses, + &i.CurrentUses, + ) + return i, err +} + +const deleteInvite = `-- name: DeleteInvite :exec +delete from invites +where id = $1 +` + +func (q *Queries) DeleteInvite(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteInvite, id) + return err +} + +const getInvite = `-- name: GetInvite :one +select id, name, created_at, expires_at, max_uses, current_uses from invites where id = $1 +` + +func (q *Queries) GetInvite(ctx context.Context, id uuid.UUID) (Invite, error) { + row := q.db.QueryRow(ctx, getInvite, id) + var i Invite + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.ExpiresAt, + &i.MaxUses, + &i.CurrentUses, + ) + return i, err +} + +const incrementInviteUses = `-- name: IncrementInviteUses :exec +update invites +set current_uses = current_uses + 1 +where id = $1 +` + +func (q *Queries) IncrementInviteUses(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, incrementInviteUses, id) + return err +} + +const listInvites = `-- name: ListInvites :many +select id, name, created_at, expires_at, max_uses, current_uses from invites +` + +func (q *Queries) ListInvites(ctx context.Context) ([]Invite, error) { + rows, err := q.db.Query(ctx, listInvites) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Invite + for rows.Next() { + var i Invite + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.ExpiresAt, + &i.MaxUses, + &i.CurrentUses, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/database/migrations/20240713201626_add_invites.sql b/pkg/database/migrations/20240713201626_add_invites.sql new file mode 100644 index 0000000..7cfce74 --- /dev/null +++ b/pkg/database/migrations/20240713201626_add_invites.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +create table invites ( + id uuid primary key default gen_random_uuid(), + name text not null, + created_at timestamp not null default now(), + expires_at timestamp not null, + max_uses int null, + current_uses int not null default 0 +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop table invites; +-- +goose StatementEnd diff --git a/pkg/database/models.go b/pkg/database/models.go index 68d890d..3cc4b65 100644 --- a/pkg/database/models.go +++ b/pkg/database/models.go @@ -9,6 +9,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Invite struct { + ID uuid.UUID + Name string + CreatedAt pgtype.Timestamp + ExpiresAt pgtype.Timestamp + MaxUses pgtype.Int4 + CurrentUses int32 +} + type LegacyapiToken struct { ID uuid.UUID Kthid string diff --git a/pkg/database/sql/invite.sql b/pkg/database/sql/invite.sql new file mode 100644 index 0000000..829e238 --- /dev/null +++ b/pkg/database/sql/invite.sql @@ -0,0 +1,23 @@ +-- name: ListInvites :many +select * from invites; + +-- name: GetInvite :one +select * from invites where id = $1; + +-- name: CreateInvite :one +insert into invites ( + name, + expires_at, + max_uses +) +values ($1, $2, $3) +returning *; + +-- name: DeleteInvite :exec +delete from invites +where id = $1; + +-- name: IncrementInviteUses :exec +update invites +set current_uses = current_uses + 1 +where id = $1; diff --git a/pkg/httputil/httputil.go b/pkg/httputil/httputil.go index 98f4742..743d1a7 100644 --- a/pkg/httputil/httputil.go +++ b/pkg/httputil/httputil.go @@ -11,40 +11,43 @@ import ( type ToResponse any -func Route(f func(w http.ResponseWriter, r *http.Request) ToResponse) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := f(w, r) - if resp == nil { - return +func Respond(resp ToResponse, w http.ResponseWriter, r *http.Request) { + if resp == nil { + return + } + switch resp.(type) { + case templ.Component: + resp.(templ.Component).Render(r.Context(), w) + case error: + err := resp.(error) + var httpErr HttpError + if errors.As(err, &httpErr) { + httpErr.ServeHTTP(w, r) + } else { + slog.Error("Error serving request", "path", r.URL.Path, "error", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) } - switch resp.(type) { - case templ.Component: - resp.(templ.Component).Render(r.Context(), w) - case error: - err := resp.(error) - var httpErr HttpError - if errors.As(err, &httpErr) { - httpErr.ServeHTTP(w, r) - } else { - slog.Error("Error serving request", "path", r.URL.Path, "error", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) - } - case string: - s := resp.(string) - w.Write([]byte(s)) - case http.Handler: - h := resp.(http.Handler) - h.ServeHTTP(w, r) - case jsonValue: - j := resp.(jsonValue).any - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(j); err != nil { - slog.Error("Error writing json", "value", j) - } - default: - slog.Error("Got invalid response type when serving request", "url", r.URL.String(), "response", resp) + case string: + s := resp.(string) + w.Write([]byte(s)) + case http.Handler: + h := resp.(http.Handler) + h.ServeHTTP(w, r) + case jsonValue: + j := resp.(jsonValue).any + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(j); err != nil { + slog.Error("Error writing json", "value", j) } + default: + slog.Error("Got invalid response type when serving request", "url", r.URL.String(), "response", resp) + } +} + +func Route(f func(w http.ResponseWriter, r *http.Request) ToResponse) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Respond(f(w, r), w, r) }) } diff --git a/services/admin/admin.go b/services/admin/admin.go index 7eecb1e..8097f0c 100644 --- a/services/admin/admin.go +++ b/services/admin/admin.go @@ -27,13 +27,20 @@ func NewService(db *database.Queries) (*service, error) { s := &service{db: db} http.Handle("GET /admin", s.auth(httputil.Route(s.admin))) - http.Handle("POST /admin/upload-sheet", s.auth(httputil.Route(s.uploadSheet))) - http.Handle("GET /admin/upload-sheet", s.auth(httputil.Route(s.processSheet))) + + http.Handle("GET /admin/members", s.auth(httputil.Route(s.members))) + http.Handle("POST /admin/members/upload-sheet", s.auth(httputil.Route(s.uploadSheet))) + http.Handle("GET /admin/members/upload-sheet", s.auth(httputil.Route(s.processSheet))) http.Handle("GET /admin/oidc-clients", s.auth(httputil.Route(s.oidcClients))) + http.Handle("GET /admin/list-oidc-clients", s.auth(httputil.Route(s.listOIDCClients))) http.Handle("POST /admin/oidc-clients", s.auth(httputil.Route(s.createOIDCClient))) http.Handle("DELETE /admin/oidc-clients/{id}", s.auth(httputil.Route(s.deleteOIDCClient))) + http.Handle("GET /admin/invites", s.auth(httputil.Route(s.invites))) + http.Handle("POST /admin/invites", s.auth(httputil.Route(s.createInvite))) + http.Handle("DELETE /admin/invites/{id}", s.auth(httputil.Route(s.deleteInvite))) + return s, nil } diff --git a/services/admin/admin.templ b/services/admin/admin.templ index ab8a646..746fb35 100644 --- a/services/admin/admin.templ +++ b/services/admin/admin.templ @@ -1,20 +1,139 @@ package admin -import "github.com/datasektionen/logout/pkg/templates" +import ( + "github.com/datasektionen/logout/pkg/config" + "github.com/datasektionen/logout/pkg/database" + "github.com/datasektionen/logout/pkg/templates" + "github.com/datasektionen/logout/services/static" + "strconv" + "time" +) templ admin() { - @templates.Page() { -
-
-
-
- + @page() +} + +templ members() { + @page() { +
+
+ +
+ } +} + +templ oidcClients() { + @page() { +
+
+ +
+ } +} + +var input = ` + border border-neutral-500 grow + outline-none focus:border-cerise-strong hover:border-cerise-light + bg-slate-800 p-1.5 rounded h-8 +` + +var button = ` + bg-[#3f4c66] p-1 h-8 block rounded border text-center + select-none border-transparent outline-none + focus:border-cerise-strong hover:border-cerise-light +` + +var roundButton = ` + bg-[#3f4c66] shrink-0 h-5 w-5 rounded-full + grid place-items-center pointer + border border-transparent outline-none focus:border-cerise-strong hover:border-cerise-light relative + [&>img]:w-3/5 [&>img]:h-3/5 [&>img]:invert +` + +templ invite(invite database.Invite) { +
  • +

    { invite.Name }

    +

    { strconv.Itoa(int(invite.CurrentUses)) }

    +

    + if invite.MaxUses.Valid { + { strconv.Itoa(int(invite.MaxUses.Int32)) } + } +

    +

    { invite.CreatedAt.Time.Format(time.DateOnly) }

    +

    { invite.ExpiresAt.Time.Format(time.DateOnly) }

    +
    + + +
    +
  • +} + +templ invites(invites []database.Invite) { + @page() { +
    +
      +
    • +

      Name

      +

      Uses

      +

      Max uses

      +

      Created at

      +

      Expires at

      +

      +
    • + for _, inv := range invites { + @invite(inv) + } +
    +
    +
    + + +
    +
    + +
    -
    -
    - +
    + +
    + + +
    + } +} + +templ page() { + @templates.Page() { + +
    +
    + { children... }
    + } } diff --git a/services/admin/admin_templ.go b/services/admin/admin_templ.go index 95f68ef..922d33b 100644 --- a/services/admin/admin_templ.go +++ b/services/admin/admin_templ.go @@ -1,23 +1,33 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.2.707 +// templ: version: v0.2.747 package admin //lint:file-ignore SA4006 This context is only used if a nested component is present. import "github.com/a-h/templ" -import "context" -import "io" -import "bytes" +import templruntime "github.com/a-h/templ/runtime" -import "github.com/datasektionen/logout/pkg/templates" +import ( + "github.com/datasektionen/logout/pkg/config" + "github.com/datasektionen/logout/pkg/database" + "github.com/datasektionen/logout/pkg/templates" + "github.com/datasektionen/logout/services/static" + "strconv" + "time" +) func admin() templ.Component { - return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { - templ_7745c5c3_Buffer = templ.GetBuffer() - defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var1 := templ.GetChildren(ctx) @@ -25,27 +35,487 @@ func admin() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + templ_7745c5c3_Err = page().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func members() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = page().Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func oidcClients() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { - templ_7745c5c3_Buffer = templ.GetBuffer() - defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = page().Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var input = ` + border border-neutral-500 grow + outline-none focus:border-cerise-strong hover:border-cerise-light + bg-slate-800 p-1.5 rounded h-8 +` + +var button = ` + bg-[#3f4c66] p-1 h-8 block rounded border text-center + select-none border-transparent outline-none + focus:border-cerise-strong hover:border-cerise-light +` + +var roundButton = ` + bg-[#3f4c66] shrink-0 h-5 w-5 rounded-full + grid place-items-center pointer + border border-transparent outline-none focus:border-cerise-strong hover:border-cerise-light relative + [&>img]:w-3/5 [&>img]:h-3/5 [&>img]:invert +` + +func invite(invite database.Invite) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(invite.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 55, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(int(invite.CurrentUses))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 56, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if invite.MaxUses.Valid { + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(int(invite.MaxUses.Int32))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 59, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(invite.CreatedAt.Time.Format(time.DateOnly)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 62, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(invite.ExpiresAt.Time.Format(time.DateOnly)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 63, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 = []any{roundButton} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 = []any{roundButton} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: templ.SafeScript( + "navigator.clipboard.writeText", + config.Config.Origin.String()+"/invite/"+invite.ID.String(), + )}) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func invites(invites []database.Invite) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var19 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { - _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    • Name

      Uses

      Max uses

      Created at

      Expires at

    • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, inv := range invites { + templ_7745c5c3_Err = invite(inv).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 = []any{input} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 = []any{input} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 = []any{input} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 = []any{button} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err } return templ_7745c5c3_Err }) - templ_7745c5c3_Err = templates.Page().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = page().Render(templ.WithChildren(ctx, templ_7745c5c3_Var19), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + return templ_7745c5c3_Err + }) +} + +func page() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var29 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var28.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = templates.Page().Render(templ.WithChildren(ctx, templ_7745c5c3_Var29), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err } return templ_7745c5c3_Err }) diff --git a/services/admin/handlers.go b/services/admin/handlers.go index 9a225f2..326aa7f 100644 --- a/services/admin/handlers.go +++ b/services/admin/handlers.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/datasektionen/logout/pkg/httputil" "github.com/datasektionen/logout/pkg/kthldap" "github.com/datasektionen/logout/pkg/pls" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -51,6 +53,57 @@ func (s *service) admin(w http.ResponseWriter, r *http.Request) httputil.ToRespo return admin() } +func (s *service) members(w http.ResponseWriter, r *http.Request) httputil.ToResponse { + return members() +} + +func (s *service) oidcClients(w http.ResponseWriter, r *http.Request) httputil.ToResponse { + return oidcClients() +} + +func (s *service) invites(w http.ResponseWriter, r *http.Request) httputil.ToResponse { + invs, err := s.db.ListInvites(r.Context()) + if err != nil { + return err + } + return invites(invs) +} + +func (s *service) createInvite(w http.ResponseWriter, r *http.Request) httputil.ToResponse { + name := r.FormValue("name") + expiresAt, err := time.Parse(time.DateOnly, r.FormValue("expires-at")) + if err != nil { + return httputil.BadRequest("Invalid date for expires at") + } + maxUsesStr := r.FormValue("max-uses") + maxUses, err := strconv.Atoi(maxUsesStr) + if err != nil && maxUsesStr != "" { + return httputil.BadRequest("Invalid int for max uses") + } + inv, err := s.db.CreateInvite(r.Context(), database.CreateInviteParams{ + Name: name, + ExpiresAt: pgtype.Timestamp{Time: expiresAt, Valid: true}, + MaxUses: pgtype.Int4{Int32: int32(maxUses), Valid: maxUsesStr != ""}, + }) + if err != nil { + return err + } + return invite(inv) +} + +func (s *service) deleteInvite(w http.ResponseWriter, r *http.Request) httputil.ToResponse { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + return httputil.BadRequest("Invalid id") + } + if err := s.db.DeleteInvite(r.Context(), id); err == pgx.ErrNoRows { + return httputil.BadRequest("No such invite") + } else if err != nil { + return err + } + return nil +} + func (s *service) uploadSheet(w http.ResponseWriter, r *http.Request) httputil.ToResponse { s.memberSheet.mu.Lock() defer s.memberSheet.mu.Unlock() @@ -243,7 +296,7 @@ func (s *service) processSheet(w http.ResponseWriter, r *http.Request) httputil. return nil } -func (s *service) oidcClients(w http.ResponseWriter, r *http.Request) httputil.ToResponse { +func (s *service) listOIDCClients(w http.ResponseWriter, r *http.Request) httputil.ToResponse { clients, err := s.db.ListClients(r.Context()) if err != nil { return err diff --git a/services/oidcrp/handlers.go b/services/oidcrp/handlers.go index be33c24..44aba59 100644 --- a/services/oidcrp/handlers.go +++ b/services/oidcrp/handlers.go @@ -45,12 +45,19 @@ func (s *service) kthCallback(w http.ResponseWriter, r *http.Request) httputil.T return } if user == nil { - // TODO: show a better user creation request note/thingie - httputil.Forbidden("Your KTH account is not connected to a Datasektionen account. This should happen automatically if you are a chapter member. If you believe this is a mistake, please contact head of IT at d-sys@datasektionen.se").(httputil.HttpError).ServeHTTP(w, r) + ok, resp := s.user.FinishInvite(w, r, kthid) + if ok { + httputil.Respond(resp, w, r) + } else { + // TODO: show a better user creation request note/thingie + httputil.Respond(httputil.Forbidden( + "Your KTH account is not connected to a Datasektionen account. This should happen "+ + "automatically if you are a chapter member and otherwise you must receive an invitation. If "+ + "you believe this is a mistake, please contact head of IT at d-sys@datasektionen.se", + ), w, r) + } return } - httputil.Route(func(w http.ResponseWriter, r *http.Request) httputil.ToResponse { - return s.user.LoginUser(r.Context(), user.KTHID) - }).ServeHTTP(w, r) + httputil.Respond(s.user.LoginUser(r.Context(), user.KTHID), w, r) }, s.relyingParty) } diff --git a/services/static/public/clipboard.svg b/services/static/public/clipboard.svg new file mode 100644 index 0000000..c46e398 --- /dev/null +++ b/services/static/public/clipboard.svg @@ -0,0 +1,4 @@ + + + + diff --git a/services/static/public/delta.svg b/services/static/public/delta.svg new file mode 100644 index 0000000..c51614f --- /dev/null +++ b/services/static/public/delta.svg @@ -0,0 +1,6 @@ + + + diff --git a/services/static/static.go b/services/static/static.go index a325805..de7d067 100644 --- a/services/static/static.go +++ b/services/static/static.go @@ -14,3 +14,11 @@ func Mount() { http.Handle("GET /public/", http.FileServerFS(public)) http.Handle("GET /dist/", http.StripPrefix("/dist/", http.FileServer(http.Dir(config.Config.DistDir)))) } + +func PublicAsString(name string) string { + res, err := public.ReadFile("public/" + name) + if err != nil { + panic(err) + } + return string(res) +} diff --git a/services/user/export/user.go b/services/user/export/user.go index 1a79d42..2c01a37 100644 --- a/services/user/export/user.go +++ b/services/user/export/user.go @@ -14,6 +14,7 @@ type Service interface { GetLoggedInKTHID(r *http.Request) (string, error) GetLoggedInUser(r *http.Request) (*User, error) Logout(w http.ResponseWriter, r *http.Request) httputil.ToResponse + FinishInvite(w http.ResponseWriter, r *http.Request, kthid string) (bool, httputil.ToResponse) } type User struct { diff --git a/services/user/handlers.go b/services/user/handlers.go index 1663f7d..50ea9d4 100644 --- a/services/user/handlers.go +++ b/services/user/handlers.go @@ -1,10 +1,16 @@ package user import ( + "errors" + "log/slog" "net/http" "time" + "github.com/datasektionen/logout/pkg/database" "github.com/datasektionen/logout/pkg/httputil" + "github.com/datasektionen/logout/pkg/kthldap" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) func (s *service) index(w http.ResponseWriter, r *http.Request) httputil.ToResponse { @@ -56,3 +62,84 @@ func (s *service) account(w http.ResponseWriter, r *http.Request) httputil.ToRes } return account(*user) } + +func (s *service) acceptInvite(w http.ResponseWriter, r *http.Request) httputil.ToResponse { + idString := r.PathValue("id") + if idString == "-" { + idCookie, _ := r.Cookie("invite") + if idCookie == nil { + return httputil.BadRequest("No invite id found") + } + idString = idCookie.Value + } + id, err := uuid.Parse(idString) + if err != nil { + return httputil.BadRequest("Invalid uuid") + } + inv, err := s.db.GetInvite(r.Context(), id) + if err == pgx.ErrNoRows { + return httputil.BadRequest("No such invite") + } else if err != nil { + return err + } + if time.Now().After(inv.ExpiresAt.Time) { + return httputil.BadRequest("Invite expired") + } + if inv.MaxUses.Valid && inv.CurrentUses >= inv.MaxUses.Int32 { + return httputil.BadRequest("This invite cannot be used to create more users") + } + http.SetCookie(w, &http.Cookie{ + Name: "invite", + Value: id.String(), + Secure: true, + HttpOnly: true, + Path: "/", + SameSite: http.SameSiteLaxMode, + }) + return acceptInvite() +} + +func (s *service) FinishInvite(w http.ResponseWriter, r *http.Request, kthid string) (bool, httputil.ToResponse) { + idCookie, _ := r.Cookie("invite") + if idCookie == nil { + return false, nil + } + id, err := uuid.Parse(idCookie.Value) + if err != nil { + return true, httputil.BadRequest("Invalid uuid") + } + inv, err := s.db.GetInvite(r.Context(), id) + if err == pgx.ErrNoRows { + return true, httputil.BadRequest("No such invite") + } else if err != nil { + return true, err + } + if time.Now().After(inv.ExpiresAt.Time) { + return true, httputil.BadRequest("Invite expired") + } + if inv.MaxUses.Valid && inv.CurrentUses >= inv.MaxUses.Int32 { + return true, httputil.BadRequest("This invite has reached its usage limit") + } + person, err := kthldap.Lookup(r.Context(), kthid) + if err != nil { + return true, err + } + if person == nil { + slog.Error("Could not find user in ldap", "kthid", kthid, "invite id", id) + return true, errors.New("Could not find user in ldap") + } + if err := s.db.CreateUser(r.Context(), database.CreateUserParams{ + Kthid: kthid, + UgKthid: person.UGKTHID, + Email: kthid + "@kth.se", + FirstName: person.FirstName, + FamilyName: person.FamilyName, + }); err != nil { + return true, err + } + if err := s.db.IncrementInviteUses(r.Context(), id); err != nil { + return true, err + } + http.SetCookie(w, &http.Cookie{Name: "invite", MaxAge: -1}) + return true, s.LoginUser(r.Context(), kthid) +} diff --git a/services/user/user.go b/services/user/user.go index 177b9a7..ad65627 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -28,6 +28,7 @@ func NewService(db *database.Queries) (*service, error) { http.Handle("GET /{$}", httputil.Route(s.index)) http.Handle("GET /logout", httputil.Route(s.Logout)) http.Handle("GET /account", httputil.Route(s.account)) + http.Handle("GET /invite/{id}", httputil.Route(s.acceptInvite)) return s, nil } diff --git a/services/user/user.templ b/services/user/user.templ index 70e5439..b712c41 100644 --- a/services/user/user.templ +++ b/services/user/user.templ @@ -45,3 +45,24 @@ templ account(user export.User) {
    } } + +templ acceptInvite() { + @templates.Modal() { +
    + + Continue with KTH +

    Pressing the button above will create a Datasektionen account using your KTH account.

    +
    + + } +} diff --git a/services/user/user_templ.go b/services/user/user_templ.go index cc38ddf..51a5548 100644 --- a/services/user/user_templ.go +++ b/services/user/user_templ.go @@ -147,3 +147,47 @@ func account(user export.User) templ.Component { return templ_7745c5c3_Err }) } + +func acceptInvite() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var9 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    Continue with KTH

    Pressing the button above will create a Datasektionen account using your KTH account.

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = templates.Modal().Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..8ed2868 --- /dev/null +++ b/todo.md @@ -0,0 +1,9 @@ +- [ ] hitta på namn +- [x] ladda upp på github +- [x] Dockerfile +- [x] migrations +- [x] sqlc? +- [x] gör en riktig frontend +- [x] riktig användardata +- [x] rigtigare frontend med typ interactive islands +- [~] oidc provider