package main
import (
"context"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/iabhishekrajput/anekdote-auth/internal/auth"
"github.com/iabhishekrajput/anekdote-auth/internal/config"
"github.com/iabhishekrajput/anekdote-auth/internal/crypto"
"github.com/iabhishekrajput/anekdote-auth/internal/handlers"
"github.com/iabhishekrajput/anekdote-auth/internal/mailer"
"github.com/iabhishekrajput/anekdote-auth/internal/middleware"
"github.com/iabhishekrajput/anekdote-auth/internal/server"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/iabhishekrajput/anekdote-auth/internal/web"
"github.com/justinas/nosurf"
)
func runAuditRetention(auditStore *postgres.AuditStore, days int) {
cutoff := time.Now().AddDate(0, 0, -days)
n, err := auditStore.DeleteOlderThan(context.Background(), cutoff)
if err != nil {
slog.Error("audit: retention cleanup failed", "err", err)
} else if n > 0 {
slog.Info("audit: retention cleanup", "deleted", n, "older_than_days", days)
}
}
func main() {
// Initialize structured logger. Level is configurable via LOG_LEVEL env var
// (debug, info, warn, error). Defaults to info.
logLevel := slog.LevelInfo
if raw := os.Getenv("LOG_LEVEL"); raw != "" {
if err := logLevel.UnmarshalText([]byte(raw)); err != nil {
slog.Warn("invalid LOG_LEVEL, defaulting to info", "value", raw)
}
}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})))
slog.Info("Starting anekdote auth server...")
cfg := config.Load()
if err := config.Validate(cfg); err != nil {
slog.Error("Invalid configuration", "error", err)
os.Exit(1)
}
// 1. Initialize Datastores
db, err := postgres.InitDB(cfg.DBDsn)
if err != nil {
slog.Error("Failed to connect to Postgres", "error", err)
os.Exit(1)
}
defer db.Close()
rdb, err := redis.InitRedis(cfg.RedisDSN)
if err != nil {
slog.Error("Failed to connect to Redis", "error", err)
os.Exit(1)
}
// 2. Load Crypto Keys
keys, err := crypto.LoadKeys(cfg.RSAPrivateKey, cfg.RSAPublicKey)
if err != nil {
slog.Error("Failed to load RSA Keys", "error", err)
os.Exit(1)
}
// 3. Initialize Stores
userStore := postgres.NewUserStore(db)
clientStore := postgres.NewClientStore(db).WithClaimsCache(rdb, time.Minute)
orgStore := postgres.NewOrgStore(db)
auditStore := postgres.NewAuditStore(db)
sessionStore := redis.NewSessionStore(rdb)
revocStore := redis.NewRevocationStore(rdb)
nonceStore := redis.NewNonceStore(rdb)
tokenStore := redis.NewTokenStore(rdb)
bloom := redis.NewUsernameBloom(rdb)
if bloomUsernames, err := userStore.ListAllUsernames(context.Background()); err != nil {
slog.Warn("bloom: failed to load usernames for filter population", "error", err)
} else if err := bloom.LoadAll(context.Background(), bloomUsernames); err != nil {
slog.Warn("bloom: failed to populate filter", "error", err)
} else {
slog.Info("bloom: filter populated", "usernames", len(bloomUsernames))
}
// 4. Initialize Core Server
issuer := cfg.AppURL
oauth2Srv, jwtGen := auth.BuildServer(clientStore, tokenStore, revocStore, keys, orgStore, issuer, rdb, userStore)
// 5. Initialize Mailer
mailSvc, err := mailer.NewMailer(cfg)
if err != nil {
slog.Warn("Failed to initialize mailer, forgot password emails may not work", "error", err)
}
// 6. Initialize Handlers
identH := handlers.NewIdentityHandler(cfg, userStore, sessionStore, mailSvc).
WithOrgSupport(orgStore, rdb).
WithBloom(bloom)
usernameH := handlers.NewUsernameHandler(userStore, bloom)
oauthH := handlers.NewOAuth2Handler(oauth2Srv, sessionStore, revocStore, keys, orgStore, clientStore, jwtGen).WithNonceStore(nonceStore)
discH := handlers.NewDiscoveryHandler(keys, cfg.AppURL)
accountH := handlers.NewAccountHandler(userStore, orgStore, sessionStore, auditStore, rdb).WithBloom(bloom)
orgH := handlers.NewOrgHandler(orgStore, userStore, clientStore, sessionStore, mailSvc, rdb, revocStore, auditStore, cfg.RedisEncryptionKey, cfg.AppURL)
adminH := handlers.NewAdminHandler(userStore, orgStore, clientStore, sessionStore, auditStore, revocStore, mailSvc, rdb)
probeH := handlers.NewProbeHandler(db, rdb)
userInfoH := handlers.NewUserInfoHandler(userStore, keys, revocStore, rdb).WithCustomClaimsReader(clientStore)
mgmtH := handlers.NewManagementHandler(keys, revocStore, clientStore, cfg.ManagementAudience).WithIssuer(cfg.AppURL).WithTokenIndex(rdb)
// 7. Audit log retention — run once on startup, then every 24 hours
runAuditRetention(auditStore, cfg.AuditRetentionDays)
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
runAuditRetention(auditStore, cfg.AuditRetentionDays)
}
}()
// 8. Init Router
router := server.NewRouter(cfg, identH, oauthH, discH, accountH, orgH, adminH, probeH, userInfoH, mgmtH, usernameH, sessionStore, userStore, rdb)
csrfHandler := nosurf.New(router)
csrfHandler.SetBaseCookie(http.Cookie{
Path: "/",
MaxAge: 365 * 24 * 60 * 60, // 1 year — matches nosurf default; must be set explicitly because SetBaseCookie replaces the entire struct
HttpOnly: true,
Secure: cfg.AppEnv == "production",
SameSite: http.SameSiteLaxMode,
})
// API endpoints that use bearer tokens (not form sessions) must be CSRF-exempt
csrfHandler.ExemptPath("/token")
csrfHandler.ExemptPath("/revoke")
csrfHandler.ExemptPath("/userinfo")
csrfHandler.ExemptRegexp("^/api/") // path.Match(*) doesn't cross slashes; regexp is required for deep paths
csrfHandler.SetFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
errStr := nosurf.Reason(r).Error()
ref := r.Referer()
if ref == "" {
ref = r.URL.Path
}
// Collapse the (attacker-influenceable) Referer to a same-origin path
// so the CSRF-error redirect can't be used as an open redirect.
u, err := url.Parse(web.SafeLocalRedirect(ref, "/"))
if err != nil {
u = &url.URL{Path: "/"}
}
q := u.Query()
q.Set("error", "Security Error: "+errStr)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}))
// Static files bypass the CSRF handler to prevent a race condition on first
// page load: parallel GET /static/* requests would each generate a new CSRF
// base token and set it as a cookie; the browser stores the last one received,
// which may not match the token embedded in the HTML form → ErrBadToken.
topMux := http.NewServeMux()
topMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
topMux.Handle("/", csrfHandler)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: middleware.RequestLogger(topMux),
}
// 8. Start Server with Graceful Shutdown
go func() {
slog.Info("Server listening", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("ListenAndServe crashed", "error", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
slog.Info("Server is shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("Server forced to shutdown", "error", err)
}
slog.Info("Server exited.")
}
package auth
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/go-oauth2/oauth2/v4"
oauth2errors "github.com/go-oauth2/oauth2/v4/errors"
goredis "github.com/go-redis/redis/v8"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/iabhishekrajput/anekdote-auth/internal/crypto"
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
)
// OrgMembershipReader is the minimal interface JWTGenerator needs.
// Implemented by *postgres.OrgStore.
type OrgMembershipReader interface {
// GetMembership returns the user's active role in the org.
// Returns "", nil if the user has no active membership (not an error).
// Returns "", err for infrastructure failures (DB timeout, etc.).
GetMembership(ctx context.Context, orgID, userID string) (role string, err error)
}
// GrantChecker verifies that a multi-org client has been granted access to an org,
// and provides the per-org scope restriction if one has been set.
// Implemented by *postgres.ClientStore.
type GrantChecker interface {
HasGrant(ctx context.Context, clientID string, orgID string) (bool, error)
// GetGrantAllowedScopes returns the scope whitelist for a grant (nil = unrestricted).
GetGrantAllowedScopes(ctx context.Context, clientID string, orgID string) (*string, error)
}
// UserReader is the minimal interface JWTGenerator needs for scope-driven claims.
// Implemented by *postgres.UserStore.
type UserReader interface {
GetByID(id string) (*models.User, error)
}
// CustomClaimsReader reads per-client custom claims filtered by scope and destination.
// Implemented by *postgres.ClientStore.
type CustomClaimsReader interface {
GetCustomClaims(ctx context.Context, clientID, grantedScope, destination string) (map[string]any, error)
GetCustomClaimsForContext(ctx context.Context, clientID, grantedScope, destination string, claimCtx postgres.CustomClaimContext) (map[string]any, error)
}
// reservedClaims is the lowercase set of claim names that may not be overridden.
var reservedClaims = map[string]struct{}{
"sub": {}, "iss": {}, "aud": {}, "exp": {}, "iat": {}, "jti": {}, "nbf": {},
"scope": {}, "org_id": {}, "org_role": {}, "name": {}, "email": {},
"email_verified": {}, "updated_at": {}, "at_hash": {},
"auth_time": {}, "nonce": {}, "acr": {}, "amr": {}, "azp": {}, "client_id": {},
"preferred_username": {},
}
// JWTGenerator implements oauth2.AccessGenerate
type JWTGenerator struct {
keyStore *crypto.KeyStore
issuer string
orgStore OrgMembershipReader
grantChecker GrantChecker
rdb *goredis.Client
userStore UserReader
claimsReader CustomClaimsReader
}
func NewJWTGenerator(keyStore *crypto.KeyStore, issuer string, orgStore OrgMembershipReader, grantChecker GrantChecker, rdb *goredis.Client, userStore UserReader, claimsReader CustomClaimsReader) *JWTGenerator {
return &JWTGenerator{
keyStore: keyStore,
issuer: issuer,
orgStore: orgStore,
grantChecker: grantChecker,
rdb: rdb,
userStore: userStore,
claimsReader: claimsReader,
}
}
// Token creates a signed JWT Access Token and an optional opaque refresh token.
func (g *JWTGenerator) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (access, refresh string, err error) {
jti := uuid.New().String()
// data.UserID may be encoded as "{userUUID}|{orgID}" when the user selected a specific
// org during multi-org consent. Split here so sub is always a plain UUID.
rawUserID := data.UserID
subUserID := rawUserID
var encodedOrgID string
if idx := strings.Index(rawUserID, "|"); idx >= 0 {
subUserID = rawUserID[:idx]
encodedOrgID = rawUserID[idx+1:]
}
// Effective scope may be narrowed by a per-org grant restriction (set later).
effectiveScope := data.TokenInfo.GetScope()
var claimCtx postgres.CustomClaimContext
// For client_credentials grants (no user context), RFC 9068 §2.2.3.1 recommends
// setting sub to the client_id. Resource servers detect service-account tokens by
// checking sub == aud (or sub == client_id claim).
subClaim := subUserID
if subClaim == "" {
subClaim = data.Client.GetID()
}
claims := jwt.MapClaims{
"iss": g.issuer,
"sub": subClaim,
"aud": data.Client.GetID(),
"exp": time.Now().Add(data.TokenInfo.GetAccessExpiresIn()).Unix(),
"iat": time.Now().Unix(),
"jti": jti,
// "scope" is set after org resolution so the per-org restriction can narrow it.
}
// Inject org claims when:
// (a) legacy single-org client: OrgClientInfo.OrgID != nil, or
// (b) multi-org consent: encodedOrgID is set in the UserID field.
// Skip for client_credentials grants (data.UserID is empty).
if subUserID != "" {
var resolvedOrgID *string
if encodedOrgID != "" {
eid := encodedOrgID
resolvedOrgID = &eid
} else if oci, ok := data.Client.(*postgres.OrgClientInfo); ok && oci.OrgID != nil {
resolvedOrgID = oci.OrgID
}
if resolvedOrgID != nil {
// Re-validate membership at token time (defends against tampering + membership removal between consent and exchange).
role, lookupErr := g.orgStore.GetMembership(ctx, *resolvedOrgID, subUserID)
if lookupErr != nil {
return "", "", fmt.Errorf("org membership lookup failed: %w", lookupErr)
}
if role == "" {
return "", "", oauth2errors.ErrAccessDenied
}
// For multi-org consent (encodedOrgID set), also verify the client has an
// active grant for the selected org. This prevents a user who is a member of
// org B from forging a token for a client that was never granted to org B.
if encodedOrgID != "" && g.grantChecker != nil {
needsGrantCheck := true
if oci, isOrgClient := data.Client.(*postgres.OrgClientInfo); isOrgClient && oci.OrgID != nil && *oci.OrgID == encodedOrgID {
needsGrantCheck = false
}
if needsGrantCheck {
ok, grantErr := g.grantChecker.HasGrant(ctx, data.Client.GetID(), *resolvedOrgID)
if grantErr != nil {
return "", "", fmt.Errorf("grant check failed: %w", grantErr)
}
if !ok {
return "", "", oauth2errors.ErrAccessDenied
}
// Enforce per-org scope restriction, if one has been set.
if allowedScopes, scopeErr := g.grantChecker.GetGrantAllowedScopes(ctx, data.Client.GetID(), *resolvedOrgID); scopeErr == nil && allowedScopes != nil {
effectiveScope = intersectScopes(effectiveScope, *allowedScopes)
}
}
}
claims["org_id"] = *resolvedOrgID
claims["org_role"] = role
claimCtx.OrgID = *resolvedOrgID
claimCtx.OrgRole = role
if g.rdb != nil {
g.rdb.SAdd(ctx, "oauth:user-org-tokens:"+subUserID+":"+*resolvedOrgID, jti)
}
}
}
// Service account: client_credentials grant with org_id binding on the client record.
// Inject org_id so the Management API can enforce org ownership without a user session.
if subUserID == "" {
if oci, ok := data.Client.(*postgres.OrgClientInfo); ok && oci.OrgID != nil {
claims["org_id"] = *oci.OrgID
claimCtx.OrgID = *oci.OrgID
}
}
claims["scope"] = effectiveScope
// Inject profile/email claims when scopes are granted (user-context tokens only).
// Use effectiveScope so per-org restrictions are respected in scope-driven claims.
var tokenUser *models.User
if subUserID != "" && g.userStore != nil {
tokenUser = g.injectScopeClaims(ctx, claims, subUserID, effectiveScope)
claimCtx.UserID = subUserID
if tokenUser == nil && g.claimsReader != nil {
tokenUser, _ = g.userStore.GetByID(subUserID)
}
if tokenUser != nil {
claimCtx.Email = tokenUser.Email
claimCtx.Name = tokenUser.Name
claimCtx.Username = tokenUser.Username
}
}
// Inject per-client custom claims (fail-closed: error blocks token issuance).
if g.claimsReader != nil {
if err := g.injectCustomClaims(ctx, claims, data.Client.GetID(), effectiveScope, "access_token", claimCtx); err != nil {
return "", "", fmt.Errorf("custom claims read failed: %w", err)
}
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = g.keyStore.KeyID
access, err = token.SignedString(g.keyStore.PrivateKey)
if err != nil {
return "", "", errors.New("internal server error signing jwt")
}
if g.rdb != nil {
g.rdb.SAdd(ctx, "oauth:client-tokens:"+data.Client.GetID(), jti)
}
// OIDC §11: only mint a refresh token when the client requested offline_access.
// Applies to user grants (authorization_code). client_credentials never receives
// a refresh token (library default; refresh_token is unnecessary for that flow).
if isGenRefresh && subUserID != "" && hasScope(effectiveScope, "offline_access") {
refresh = uuid.New().String()
}
return access, refresh, nil
}
// injectScopeClaims adds email/profile claims to dst when the scope grants them.
// Uses exact-word matching to prevent false positives on scopes like "email_read".
func (g *JWTGenerator) injectScopeClaims(ctx context.Context, dst jwt.MapClaims, userIDStr, scope string) *models.User {
scopeSet := make(map[string]bool)
for s := range strings.FieldsSeq(scope) {
scopeSet[s] = true
}
if !scopeSet["profile"] && !scopeSet["email"] {
return nil
}
user, err := g.userStore.GetByID(userIDStr)
if err != nil {
slog.Warn("user lookup failed for scope claims; claims omitted", "user_id", userIDStr, "error", err)
return nil
}
if scopeSet["profile"] {
if user.Name != "" {
dst["name"] = user.Name
}
if user.Username != "" {
dst["preferred_username"] = user.Username
}
dst["updated_at"] = user.UpdatedAt.Unix()
}
if scopeSet["email"] {
dst["email"] = user.Email
dst["email_verified"] = user.IsVerified
}
return user
}
// hasScope reports whether scope (space-delimited) contains target as an exact token.
func hasScope(scope, target string) bool {
return slices.Contains(strings.Fields(scope), target)
}
// intersectScopes returns only the scopes from requested that are present in allowed.
func intersectScopes(requested, allowed string) string {
allowedSet := make(map[string]bool)
for s := range strings.FieldsSeq(allowed) {
allowedSet[s] = true
}
var result []string
for s := range strings.FieldsSeq(requested) {
if allowedSet[s] {
result = append(result, s)
}
}
return strings.Join(result, " ")
}
// GenerateIDToken creates a signed OIDC ID token for the authorization_code flow.
// sub is the user UUID string, aud is the client_id, accessToken is the just-issued access token.
// nonce is echoed back when non-empty (OIDC Core §3.1.3.6).
func (g *JWTGenerator) GenerateIDToken(ctx context.Context, sub, aud, scope, accessToken string, expiry time.Duration, nonce string) (string, error) {
// at_hash: left half of SHA256 of the access token, base64url-encoded (OIDC Core §3.3.2.9)
h := sha256.Sum256([]byte(accessToken))
atHash := base64.RawURLEncoding.EncodeToString(h[:len(h)/2])
now := time.Now()
claims := jwt.MapClaims{
"iss": g.issuer,
"sub": sub,
"aud": aud,
"exp": now.Add(expiry).Unix(),
"iat": now.Unix(),
"at_hash": atHash,
}
if nonce != "" {
claims["nonce"] = nonce
}
claimCtx := postgres.CustomClaimContext{UserID: sub}
parser := jwt.NewParser()
if parsed, _, parseErr := parser.ParseUnverified(accessToken, jwt.MapClaims{}); parseErr == nil {
if accessClaims, ok := parsed.Claims.(jwt.MapClaims); ok {
if v, _ := accessClaims["email"].(string); v != "" {
claims["email"] = v
claimCtx.Email = v
}
if v, _ := accessClaims["name"].(string); v != "" {
claims["name"] = v
claimCtx.Name = v
}
if v, _ := accessClaims["preferred_username"].(string); v != "" {
claims["preferred_username"] = v
claimCtx.Username = v
}
if v, ok := accessClaims["email_verified"].(bool); ok {
claims["email_verified"] = v
}
if v, ok := accessClaims["updated_at"].(float64); ok {
claims["updated_at"] = int64(v)
}
if v, _ := accessClaims["org_id"].(string); v != "" {
claimCtx.OrgID = v
}
if v, _ := accessClaims["org_role"].(string); v != "" {
claimCtx.OrgRole = v
}
}
}
// Inject per-client custom claims into id_token.
if g.claimsReader != nil {
if err := g.injectCustomClaims(ctx, claims, aud, scope, "id_token", claimCtx); err != nil {
return "", fmt.Errorf("custom claims read failed for id_token: %w", err)
}
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = g.keyStore.KeyID
signed, err := token.SignedString(g.keyStore.PrivateKey)
if err != nil {
return "", errors.New("internal server error signing id_token")
}
return signed, nil
}
// injectCustomClaims reads per-client custom claims filtered by scope and destination,
// and merges them into dst. Reserved keys are silently skipped (defensive guard).
func (g *JWTGenerator) injectCustomClaims(ctx context.Context, dst jwt.MapClaims, clientID, grantedScope, destination string, claimCtx postgres.CustomClaimContext) error {
custom, err := g.claimsReader.GetCustomClaimsForContext(ctx, clientID, grantedScope, destination, claimCtx)
if err != nil {
return err
}
for k, v := range custom {
if _, reserved := reservedClaims[strings.ToLower(k)]; reserved {
slog.Warn("custom claim key is reserved; skipping", "key", k, "client_id", clientID)
continue
}
switch v.(type) {
case string, float64, bool:
dst[k] = v
default:
slog.Warn("custom claim has unsupported value type; skipping", "key", k, "client_id", clientID)
}
}
return nil
}
package auth
import (
"log/slog"
"time"
"github.com/go-oauth2/oauth2/v4"
"github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/server"
oredis "github.com/go-oauth2/redis/v4"
goredis "github.com/go-redis/redis/v8"
"github.com/iabhishekrajput/anekdote-auth/internal/crypto"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
)
func BuildServer(
clientStore *postgres.ClientStore,
tokenStore *oredis.TokenStore,
revStore *redis.RevocationStore,
keyStore *crypto.KeyStore,
orgReader OrgMembershipReader,
issuer string,
rdb *goredis.Client,
userStore UserReader,
) (*server.Server, *JWTGenerator) {
manager := manage.NewDefaultManager()
manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
manager.MapClientStorage(clientStore)
manager.MapTokenStorage(tokenStore)
jwtGen := NewJWTGenerator(keyStore, issuer, orgReader, clientStore, rdb, userStore, clientStore)
manager.MapAccessGenerate(jwtGen)
srv := server.NewDefaultServer(manager)
srv.SetAllowGetAccessRequest(false)
srv.SetClientInfoHandler(server.ClientFormHandler)
srv.Config.ForcePKCE = true
srv.SetAllowedGrantType(
oauth2.AuthorizationCode,
oauth2.Refreshing,
oauth2.ClientCredentials,
)
manager.SetAuthorizeCodeExp(time.Minute * 10)
slog.Info("OAuth2 Server Manager Initialized", "issuer", issuer)
return srv, jwtGen
}
package config
import (
"bufio"
"encoding/hex"
"errors"
"log/slog"
"os"
"strconv"
"strings"
)
type Config struct {
Port string
AppURL string
DBDsn string
RedisDSN string
RSAPrivateKey string
RSAPublicKey string
SessionSecret string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPFrom string
SMTPInsecureSkipVerify bool
AppEnv string
CORSAllowedOrigins string
// RedisEncryptionKey is a 32-byte AES key used to encrypt sensitive values
// in Redis (e.g. OAuth2 client secret flash). Set REDIS_ENCRYPTION_KEY to
// 64 lowercase hex characters (32 bytes). In production the server refuses
// to start if this is not set to a valid key.
RedisEncryptionKey []byte
// AuditRetentionDays is how many days to keep admin audit log entries.
// Entries older than this are deleted on startup and daily thereafter.
// Default: 90. Set AUDIT_RETENTION_DAYS to override.
AuditRetentionDays int
// ManagementAudience is the expected `aud` claim for Management API tokens
// (e.g. https://auth.example.com/api/v1/). Tokens without this exact audience
// are rejected by the Management API. Defaults to AppURL+"/api/v1/".
// Set MANAGEMENT_AUDIENCE to override.
ManagementAudience string
}
func Load() *Config {
if err := loadDotEnv(".env"); err != nil {
slog.Warn("Failed to load .env file", "error", err)
}
port := getEnvOrDefault("PORT", "8080")
dbDsn := getEnvOrDefault("DB_DSN", "postgres://authuser:authpassword@localhost:5432/authdb?sslmode=disable")
redisDsn := getEnvOrDefault("REDIS_URL", "redis://localhost:6379/0")
rsaPrivate := getEnvOrDefault("RSA_PRIVATE_KEY_PATH", "certs/private.pem")
rsaPublic := getEnvOrDefault("RSA_PUBLIC_KEY_PATH", "certs/public.pem")
sessionSecret := getEnvOrDefault("SESSION_SECRET", "super-secret-session-key-change-in-prod")
smtpHost := getEnvOrDefault("SMTP_HOST", "localhost")
smtpPort := getEnvOrDefault("SMTP_PORT", "1025")
smtpUser := getEnvOrDefault("SMTP_USERNAME", "test")
smtpPass := getEnvOrDefault("SMTP_PASSWORD", "test")
smtpFrom := getEnvOrDefault("SMTP_FROM", "noreply@anekdoteauth.local")
smtpInsecureSkipVerify := getEnvOrDefault("SMTP_INSECURE_SKIP_VERIFY", "false") == "true"
appEnv := getEnvOrDefault("APP_ENV", "development")
appURL := getEnvOrDefault("APP_URL", "http://localhost:"+port)
corsAllowed := getEnvOrDefault("CORS_ALLOWED_ORIGINS", "http://localhost:8080")
encKey := parseHexKey(getEnvOrDefault("REDIS_ENCRYPTION_KEY", ""))
if encKey == nil && appEnv != "production" {
// Insecure dev-only key; logged so it's impossible to miss.
slog.Warn("REDIS_ENCRYPTION_KEY not set — using insecure dev key; never run this in production")
encKey = []byte("dev-insecure-key-do-not-use-prod")
}
retentionDays := 90
if v, _ := strconv.Atoi(os.Getenv("AUDIT_RETENTION_DAYS")); v > 0 {
retentionDays = v
}
mgmtAudience := getEnvOrDefault("MANAGEMENT_AUDIENCE", appURL+"/api/v1/")
slog.Info("Configuration loaded", "port", port, "env", appEnv)
return &Config{
Port: port,
AppURL: appURL,
DBDsn: dbDsn,
RedisDSN: redisDsn,
RSAPrivateKey: rsaPrivate,
RSAPublicKey: rsaPublic,
SessionSecret: sessionSecret,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUsername: smtpUser,
SMTPPassword: smtpPass,
SMTPFrom: smtpFrom,
SMTPInsecureSkipVerify: smtpInsecureSkipVerify,
AppEnv: appEnv,
CORSAllowedOrigins: corsAllowed,
RedisEncryptionKey: encKey,
AuditRetentionDays: retentionDays,
ManagementAudience: mgmtAudience,
}
}
const defaultSessionSecret = "super-secret-session-key-change-in-prod"
func Validate(cfg *Config) error {
if cfg.AppEnv == "production" {
if cfg.SessionSecret == defaultSessionSecret || cfg.SessionSecret == "" {
return errors.New("SESSION_SECRET is set to an insecure value; set a random 32+ byte secret before running in production")
}
if len(cfg.RedisEncryptionKey) != 32 {
return errors.New("REDIS_ENCRYPTION_KEY must be set to 64 hex chars (32 bytes) in production")
}
}
return nil
}
// parseHexKey decodes a 64-char hex string to a 32-byte key. Returns nil on error.
func parseHexKey(s string) []byte {
if s == "" {
return nil
}
b, err := hex.DecodeString(s)
if err != nil || len(b) != 32 {
return nil
}
return b
}
// loadDotEnv reads KEY=VALUE pairs from path and sets them as environment
// variables. It is a no-op if the file does not exist. Existing variables
// are never overridden, so shell exports and container-injected vars always win.
// Lines starting with # and empty lines are ignored. Inline # comments are stripped.
func loadDotEnv(path string) error {
f, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
// Strip inline comment (e.g. VALUE # comment)
if idx := strings.Index(v, " #"); idx >= 0 {
v = v[:idx]
}
v = strings.TrimSpace(v)
if _, exists := os.LookupEnv(k); !exists {
_ = os.Setenv(k, v)
}
}
return scanner.Err()
}
func getEnvOrDefault(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return fallback
}
package crypto
import (
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"os"
"github.com/golang-jwt/jwt/v5"
)
type KeyStore struct {
PrivateKey *rsa.PrivateKey
PublicKey *rsa.PublicKey
KeyID string
}
func LoadKeys(privPath, pubPath string) (*KeyStore, error) {
privBytes, err := os.ReadFile(privPath)
if err != nil {
return nil, err
}
privKey, err := jwt.ParseRSAPrivateKeyFromPEM(privBytes)
if err != nil {
return nil, err
}
pubBytes, err := os.ReadFile(pubPath)
if err != nil {
return nil, err
}
pubKey, err := jwt.ParseRSAPublicKeyFromPEM(pubBytes)
if err != nil {
return nil, err
}
derBytes := x509.MarshalPKCS1PublicKey(pubKey)
hash := sha256.Sum256(derBytes)
keyID := base64.RawURLEncoding.EncodeToString(hash[:])
return &KeyStore{
PrivateKey: privKey,
PublicKey: pubKey,
KeyID: keyID,
}, nil
}
package handlers
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"time"
goredis "github.com/go-redis/redis/v8"
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/iabhishekrajput/anekdote-auth/internal/types"
"github.com/iabhishekrajput/anekdote-auth/internal/web"
"github.com/iabhishekrajput/anekdote-auth/web/ui"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf"
"golang.org/x/crypto/bcrypt"
)
type AccountHandler struct {
userStore *postgres.UserStore
orgStore *postgres.OrgStore
sessionStore *redis.SessionStore
auditStore *postgres.AuditStore
rdb *goredis.Client
bloom *redis.UsernameBloom
}
func (h *AccountHandler) WithBloom(bloom *redis.UsernameBloom) *AccountHandler {
h.bloom = bloom
return h
}
func NewAccountHandler(uStore *postgres.UserStore, orgStore *postgres.OrgStore, sessionStore *redis.SessionStore, auditStore *postgres.AuditStore, rdb *goredis.Client) *AccountHandler {
return &AccountHandler{
userStore: uStore,
orgStore: orgStore,
sessionStore: sessionStore,
auditStore: auditStore,
rdb: rdb,
}
}
func (h *AccountHandler) render(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
if errStr := r.URL.Query().Get("error"); errStr != "" {
if _, exists := data["Error"]; !exists {
data["Error"] = errStr
}
}
if msgStr := r.URL.Query().Get("message"); msgStr != "" {
if _, exists := data["Success"]; !exists {
data["Success"] = msgStr
}
}
var errorMsg, successMsg string
if v, ok := data["Error"].(string); ok {
errorMsg = v
}
if v, ok := data["Success"].(string); ok {
successMsg = v
}
csrfToken := nosurf.Token(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
switch name {
case "account.tmpl":
user, _ := data["User"].(*models.User)
orgs, _ := data["Orgs"].([]postgres.OrgListItem)
component := ui.AccountPage(csrfToken, user, isAdmin, orgs, errorMsg, successMsg)
_ = component.Render(r.Context(), w)
default:
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("template not found"))
}
}
func (h *AccountHandler) ViewAccount(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
user, err := h.userStore.GetByID(userID)
if err != nil {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Session user not found"), http.StatusFound)
return
}
orgs, _ := h.orgStore.ListOrgsForUserFull(r.Context(), userID)
if orgs == nil {
orgs = []postgres.OrgListItem{}
}
errMsg := r.URL.Query().Get("error")
successMsg := r.URL.Query().Get("message")
h.render(w, r, "account.tmpl", map[string]interface{}{
"User": user,
"Orgs": orgs,
"Error": errMsg,
"Success": successMsg,
})
}
func (h *AccountHandler) UpdateProfile(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
newName := strings.TrimSpace(r.FormValue("name"))
newUsername := strings.ToLower(strings.TrimSpace(r.FormValue("username")))
if newName == "" {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Name cannot be empty"), http.StatusFound)
return
}
if newUsername != "" && !usernameRegex.MatchString(newUsername) {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Username must be 3–30 lowercase letters, numbers, or underscores"), http.StatusFound)
return
}
if err := h.userStore.UpdateName(userID, newName); err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Failed to update profile"), http.StatusFound)
return
}
if err := h.userStore.UpdateUsername(r.Context(), userID, newUsername); err != nil {
msg := "Failed to update username"
if errors.Is(err, postgres.ErrUsernameTaken) {
msg = "Username is already taken"
}
http.Redirect(w, r, "/account?error="+url.QueryEscape(msg), http.StatusFound)
return
}
if h.bloom != nil && newUsername != "" {
_ = h.bloom.Add(r.Context(), newUsername)
}
http.Redirect(w, r, "/account?message="+url.QueryEscape("Profile updated"), http.StatusFound)
}
func (h *AccountHandler) UpdatePassword(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
oldPassword := r.FormValue("old_password")
newPassword := r.FormValue("new_password")
if oldPassword == "" || newPassword == "" {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Missing passwords"), http.StatusFound)
return
}
if err := validatePassword(newPassword); err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
user, err := h.userStore.GetByID(userID)
if err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("User not found"), http.StatusFound)
return
}
// Verify old password
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword))
if err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Incorrect old password"), http.StatusFound)
return
}
// Hash new password
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Server Error"), http.StatusFound)
return
}
err = h.userStore.UpdatePassword(userID, string(hash))
if err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Failed to update password"), http.StatusFound)
return
}
http.Redirect(w, r, "/account?message="+url.QueryEscape("Password updated"), http.StatusFound)
}
// userDeletionTombstoneTTL matches the OAuth2 access token max lifetime so
// tokens issued before deletion are rejected at /userinfo until they expire.
const userDeletionTombstoneTTL = 2 * time.Hour
// DeleteSelf handles POST /account/delete — self-service account deletion.
func (h *AccountHandler) DeleteSelf(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
if err := h.userStore.DeleteUser(r.Context(), userID); err != nil {
if errors.Is(err, postgres.ErrUserOwnsOrg) {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Transfer or delete your organizations before deleting your account"), http.StatusFound)
return
}
http.Redirect(w, r, "/account?error="+url.QueryEscape("Failed to delete account"), http.StatusFound)
return
}
// Revoke all sessions.
_ = h.sessionStore.DeleteAllForUser(r.Context(), userID)
// Tombstone for in-flight JWTs — checked by /userinfo.
if h.rdb != nil {
h.rdb.Set(r.Context(), "deleted:user:"+userID, "1", userDeletionTombstoneTTL)
}
// Audit log — fire-and-forget; deletion has already committed.
if h.auditStore != nil {
go func() {
_ = h.auditStore.Log(context.Background(), userID, postgres.AuditActionDeleteUser,
"user", userID, "", "self")
}()
}
// Clear session cookie and redirect to login.
web.ClearSessionCookie(w, r)
http.Redirect(w, r, "/login?message="+url.QueryEscape("Your account has been deleted"), http.StatusFound)
}
package handlers
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
goredis "github.com/go-redis/redis/v8"
"github.com/iabhishekrajput/anekdote-auth/internal/mailer"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/iabhishekrajput/anekdote-auth/internal/types"
"github.com/iabhishekrajput/anekdote-auth/web/ui"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf"
)
type AdminHandler struct {
userStore *postgres.UserStore
orgStore *postgres.OrgStore
clientStore *postgres.ClientStore
sessionStore *redis.SessionStore
auditStore *postgres.AuditStore
revocStore *redis.RevocationStore
mailer *mailer.Mailer
rdb *goredis.Client
}
func NewAdminHandler(uStore *postgres.UserStore, oStore *postgres.OrgStore, cStore *postgres.ClientStore, sStore *redis.SessionStore, aStore *postgres.AuditStore, revocStore *redis.RevocationStore, mailSvc *mailer.Mailer, rdb *goredis.Client) *AdminHandler {
return &AdminHandler{
userStore: uStore,
orgStore: oStore,
clientStore: cStore,
sessionStore: sStore,
auditStore: aStore,
revocStore: revocStore,
mailer: mailSvc,
rdb: rdb,
}
}
func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctx := r.Context()
var dbErr bool
userCount, err := h.userStore.CountAll(ctx)
if err != nil {
slog.Error("admin: count users", "err", err)
dbErr = true
}
orgCount, err := h.orgStore.CountAll(ctx)
if err != nil {
slog.Error("admin: count orgs", "err", err)
dbErr = true
}
clientCount, err := h.clientStore.CountAll(ctx)
if err != nil {
slog.Error("admin: count clients", "err", err)
dbErr = true
}
grantCount, err := h.clientStore.CountAllGrants(ctx)
if err != nil {
slog.Error("admin: count grants", "err", err)
dbErr = true
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminDashboard(nosurf.Token(r), userCount, orgCount, clientCount, grantCount, dbErr).Render(ctx, w)
}
func (h *AdminHandler) UserList(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctx := r.Context()
const pageSize = 50
cursor, err := postgres.DecodeCursor(r.URL.Query().Get("cursor"))
if err != nil {
http.Redirect(w, r, "/admin/users?error="+url.QueryEscape("Invalid pagination cursor"), http.StatusFound)
return
}
users, nextCursor, total, listErr := h.userStore.ListAllCursor(ctx, pageSize, cursor)
if listErr != nil {
slog.Error("admin: list users", "err", listErr)
}
errMsg := r.URL.Query().Get("error")
if listErr != nil && errMsg == "" {
errMsg = "Database error — data may be incomplete"
}
cursorParam := r.URL.Query().Get("cursor")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminUserList(nosurf.Token(r), users, total, cursorParam, nextCursor,
errMsg, r.URL.Query().Get("message")).Render(ctx, w)
}
func (h *AdminHandler) UserDetail(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
id := ps.ByName("id")
user, err := h.userStore.GetByID(id)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
orgs, _ := h.orgStore.ListOrgsForUserFull(ctx, id)
adminCount, _ := h.userStore.CountAdmins(ctx)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminUserDetail(nosurf.Token(r), user, orgs, adminCount <= 1,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(ctx, w)
}
func (h *AdminHandler) DisableUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
id := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if id == adminID {
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("You cannot disable your own account"), http.StatusFound)
return
}
if err := h.userStore.SetDisabled(ctx, id, true); err != nil {
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("Failed to disable user"), http.StatusFound)
return
}
_ = h.sessionStore.DeleteAllForUser(ctx, id)
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionDisableUser,
"user", id, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/users/"+id+"?message="+url.QueryEscape("User disabled and sessions revoked"), http.StatusFound)
}
func (h *AdminHandler) EnableUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
id := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if err := h.userStore.SetDisabled(ctx, id, false); err != nil {
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("Failed to enable user"), http.StatusFound)
return
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionEnableUser,
"user", id, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/users/"+id+"?message="+url.QueryEscape("User enabled"), http.StatusFound)
}
func (h *AdminHandler) PromoteAdmin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
id := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if err := h.userStore.SetAdmin(ctx, id, true); err != nil {
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("Failed to grant admin access"), http.StatusFound)
return
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionPromoteAdmin,
"user", id, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/users/"+id+"?message="+url.QueryEscape("Admin access granted"), http.StatusFound)
}
func (h *AdminHandler) DemoteAdmin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
id := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if err := h.userStore.SetAdmin(ctx, id, false); err != nil {
if errors.Is(err, postgres.ErrLastAdmin) {
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("Cannot remove the last admin"), http.StatusFound)
return
}
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("Failed to remove admin access"), http.StatusFound)
return
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionDemoteAdmin,
"user", id, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/users/"+id+"?message="+url.QueryEscape("Admin access removed"), http.StatusFound)
}
// ChangeAdminRole updates a user's admin_role (superadmin / readonly / org_admin).
// Only meaningful when the target user is already an admin (is_admin = true).
func (h *AdminHandler) ChangeAdminRole(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
id := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
role := r.FormValue("role")
if err := h.userStore.SetAdminRole(ctx, id, role); err != nil {
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("Invalid role: "+err.Error()), http.StatusFound)
return
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionChangeAdminRole,
"user", id, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/users/"+id+"?message="+url.QueryEscape("Admin role updated to "+role), http.StatusFound)
}
func (h *AdminHandler) ClientList(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctx := r.Context()
const pageSize = 50
cursor, err := postgres.DecodeCursor(r.URL.Query().Get("cursor"))
if err != nil {
http.Redirect(w, r, "/admin/clients?error="+url.QueryEscape("Invalid pagination cursor"), http.StatusFound)
return
}
withClaimsOnly := r.URL.Query().Get("with_claims") == "1"
clients, nextCursor, total, listErr := h.clientStore.ListAllCursorFiltered(ctx, pageSize, cursor, withClaimsOnly)
if listErr != nil {
slog.Error("admin: list clients", "err", listErr)
}
errMsg := r.URL.Query().Get("error")
if listErr != nil && errMsg == "" {
errMsg = "Database error — data may be incomplete"
}
cursorParam := r.URL.Query().Get("cursor")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminClientList(nosurf.Token(r), clients, total, cursorParam, nextCursor,
withClaimsOnly, errMsg, r.URL.Query().Get("message")).Render(ctx, w)
}
func (h *AdminHandler) DeleteClient(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
clientID := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if r.FormValue("confirm") != "yes" {
http.Redirect(w, r, "/admin/clients?error="+url.QueryEscape("Confirmation required"), http.StatusFound)
return
}
if err := h.clientStore.DeleteAny(ctx, clientID); err != nil {
http.Redirect(w, r, "/admin/clients?error="+url.QueryEscape("Failed to delete client"), http.StatusFound)
return
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionDeleteClient,
"client", clientID, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/clients?message="+url.QueryEscape("Client deleted"), http.StatusFound)
}
func (h *AdminHandler) OrgList(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctx := r.Context()
const pageSize = 50
cursor, err := postgres.DecodeCursor(r.URL.Query().Get("cursor"))
if err != nil {
http.Redirect(w, r, "/admin/orgs?error="+url.QueryEscape("Invalid pagination cursor"), http.StatusFound)
return
}
orgs, nextCursor, total, listErr := h.orgStore.ListAllCursor(ctx, pageSize, cursor)
if listErr != nil {
slog.Error("admin: list orgs", "err", listErr)
}
errMsg := r.URL.Query().Get("error")
if listErr != nil && errMsg == "" {
errMsg = "Database error — data may be incomplete"
}
cursorParam := r.URL.Query().Get("cursor")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminOrgList(nosurf.Token(r), orgs, total, cursorParam, nextCursor,
errMsg, r.URL.Query().Get("message")).Render(ctx, w)
}
func (h *AdminHandler) OrgDetail(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
slug := ps.ByName("slug")
org, err := h.orgStore.GetOrgBySlug(ctx, slug)
if err != nil || org == nil {
http.Redirect(w, r, "/admin/orgs?error="+url.QueryEscape("Org not found"), http.StatusFound)
return
}
members, _ := h.orgStore.ListMembers(ctx, org.ID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminOrgDetail(nosurf.Token(r), org, members,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(ctx, w)
}
func (h *AdminHandler) RemoveOrgMember(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
slug := ps.ByName("slug")
targetUserID := ps.ByName("user_id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if targetUserID == adminID {
http.Redirect(w, r, "/admin/orgs/"+slug+"?error="+url.QueryEscape("You cannot remove yourself via the admin panel"), http.StatusFound)
return
}
org, err := h.orgStore.GetOrgBySlug(ctx, slug)
if err != nil || org == nil {
http.Redirect(w, r, "/admin/orgs?error="+url.QueryEscape("Org not found"), http.StatusFound)
return
}
if err := h.orgStore.RemoveMember(ctx, org.ID, targetUserID); err != nil {
errMsg := "Failed to remove member"
if errors.Is(err, postgres.ErrOwnerCannotBeRemoved) {
errMsg = "Cannot remove the org owner; transfer ownership first"
}
http.Redirect(w, r, "/admin/orgs/"+slug+"?error="+url.QueryEscape(errMsg), http.StatusFound)
return
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionRemoveOrgMember,
"org_member", org.ID+"/"+targetUserID, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/orgs/"+slug+"?message="+url.QueryEscape("Member removed"), http.StatusFound)
}
// AuditLog renders the paginated, filtered admin audit log.
func (h *AdminHandler) AuditLog(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctx := r.Context()
const pageSize = 50
cursor, err := postgres.DecodeCursor(r.URL.Query().Get("cursor"))
if err != nil {
http.Redirect(w, r, "/admin/audit?error="+url.QueryEscape("Invalid pagination cursor"), http.StatusFound)
return
}
filter := parseAuditFilter(r)
entries, nextCursor, total, listErr := h.auditStore.ListAuditCursor(ctx, pageSize, cursor, filter)
if listErr != nil {
slog.Error("admin: list audit log", "err", listErr)
}
errMsg := r.URL.Query().Get("error")
if listErr != nil && errMsg == "" {
errMsg = "Could not load audit log — data may be incomplete"
}
cursorParam := r.URL.Query().Get("cursor")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminAuditLog(nosurf.Token(r), entries, total, cursorParam, nextCursor, filter,
errMsg, r.URL.Query().Get("message")).Render(ctx, w)
}
// ExportAuditCSV streams the filtered audit log as a CSV download.
func (h *AdminHandler) ExportAuditCSV(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
filter := parseAuditFilter(r)
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="audit-%s.csv"`, time.Now().UTC().Format("20060102-150405")))
if err := h.auditStore.ExportAuditCSV(r.Context(), filter, w); err != nil {
slog.Error("admin: export audit CSV", "err", err)
}
}
// parseAuditFilter reads filter query params from the request.
func parseAuditFilter(r *http.Request) postgres.AuditFilter {
var f postgres.AuditFilter
if adminIDStr := r.URL.Query().Get("admin_id"); adminIDStr != "" {
f.AdminID = &adminIDStr
}
if action := r.URL.Query().Get("action"); action != "" {
f.Action = action
}
if fromStr := r.URL.Query().Get("from"); fromStr != "" {
if t, err := time.Parse("2006-01-02", fromStr); err == nil {
f.From = &t
}
}
if toStr := r.URL.Query().Get("to"); toStr != "" {
if t, err := time.Parse("2006-01-02", toStr); err == nil {
// end of the day
eod := t.Add(24*time.Hour - time.Second)
f.To = &eod
}
}
return f
}
// DeleteUser handles POST /admin/users/:id/delete — superadmin hard-deletes (soft) a user.
func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
id := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if id == adminID {
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape("You cannot delete your own account via admin panel"), http.StatusFound)
return
}
if err := h.userStore.DeleteUser(ctx, id); err != nil {
errMsg := "Failed to delete user"
if errors.Is(err, postgres.ErrUserOwnsOrg) {
errMsg = "User owns organizations; delete those orgs first"
}
http.Redirect(w, r, "/admin/users/"+id+"?error="+url.QueryEscape(errMsg), http.StatusFound)
return
}
_ = h.sessionStore.DeleteAllForUser(ctx, id)
if h.rdb != nil {
h.rdb.Set(ctx, "deleted:user:"+id, "1", 2*time.Hour)
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionDeleteUser,
"user", id, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/users?message="+url.QueryEscape("User deleted"), http.StatusFound)
}
// DeleteOrg handles POST /admin/orgs/:slug/delete — superadmin deletes an org.
func (h *AdminHandler) DeleteOrg(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
slug := ps.ByName("slug")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
org, err := h.orgStore.GetOrgBySlug(ctx, slug)
if err != nil || org == nil {
http.Redirect(w, r, "/admin/orgs?error="+url.QueryEscape("Org not found"), http.StatusFound)
return
}
orgID := org.ID
if err := h.orgStore.DeleteOrg(ctx, orgID); err != nil {
http.Redirect(w, r, "/admin/orgs/"+slug+"?error="+url.QueryEscape("Failed to delete org"), http.StatusFound)
return
}
// Clean up pending Redis invite keys.
if h.rdb != nil {
tokens, err := h.rdb.SMembers(ctx, "org:invites:"+orgID).Result()
if err == nil {
for _, token := range tokens {
h.rdb.Del(ctx, "org:invite:"+token)
}
h.rdb.Del(ctx, "org:invites:"+orgID)
}
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionDeleteOrg,
"org", orgID, extractIP(r), r.Header.Get("User-Agent"))
}()
http.Redirect(w, r, "/admin/orgs?message="+url.QueryEscape("Organization deleted"), http.StatusFound)
}
// GrantList handles GET /admin/grants — shows all client_org_grants with revoke actions.
func (h *AdminHandler) GrantList(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctx := r.Context()
const pageSize = 50
offset := 0
if p := r.URL.Query().Get("page"); p != "" {
var pg int
if _, err := fmt.Sscanf(p, "%d", &pg); err == nil && pg > 1 {
offset = (pg - 1) * pageSize
}
}
grants, total, listErr := h.clientStore.ListAllClientOrgGrants(ctx, pageSize, offset)
if listErr != nil {
slog.Error("admin: list grants", "err", listErr)
}
errMsg := r.URL.Query().Get("error")
if listErr != nil && errMsg == "" {
errMsg = "Database error — data may be incomplete"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminGrantList(nosurf.Token(r), grants, total, offset/pageSize+1, pageSize,
errMsg, r.URL.Query().Get("message")).Render(ctx, w)
}
// RevokeGrant handles POST /admin/grants/:clientID/:orgID/revoke — superadmin removes a grant.
func (h *AdminHandler) RevokeGrant(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
clientID := ps.ByName("clientID")
orgID := ps.ByName("orgID")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
reason := strings.TrimSpace(r.FormValue("reason"))
if err := h.clientStore.RevokeOrgAccess(ctx, clientID, orgID); err != nil {
if errors.Is(err, postgres.ErrGrantNotFound) {
http.Redirect(w, r, "/admin/grants?error="+url.QueryEscape("Grant not found"), http.StatusFound)
return
}
http.Redirect(w, r, "/admin/grants?error="+url.QueryEscape("Failed to revoke grant"), http.StatusFound)
return
}
// Blocklist all outstanding JTIs for users of this org.
if h.rdb != nil && h.revocStore != nil {
pattern := "oauth:user-org-tokens:*:" + orgID
if keys, scanErr := h.rdb.Keys(ctx, pattern).Result(); scanErr == nil {
for _, key := range keys {
if jtis, err := h.rdb.SMembers(ctx, key).Result(); err == nil {
for _, jti := range jtis {
_ = h.revocStore.RevokeJTI(ctx, jti, 2*time.Hour)
}
}
}
}
}
go func() {
_ = h.auditStore.Log(context.WithoutCancel(ctx), adminID, postgres.AuditActionRevokeOrgClient,
"grant", clientID+"/"+orgID, extractIP(r), r.Header.Get("User-Agent"))
}()
// Notify grantedOrg's admins that access was removed.
if h.mailer != nil {
go func() {
emails, err := h.userStore.ListOrgAdmins(context.Background(), orgID)
if err != nil || len(emails) == 0 {
return
}
clientName, _ := h.clientStore.GetClientName(context.Background(), clientID)
if clientName == "" {
clientName = clientID
}
var orgName string
if org, err := h.orgStore.GetOrgByID(context.Background(), orgID); err == nil && org != nil {
orgName = org.DisplayName
}
if err := h.mailer.SendGrantRevoked(context.Background(), emails, clientName, orgName, true, reason); err != nil {
slog.Error("admin grant revoked email failed", "client", clientID, "org", orgID, "err", err)
}
}()
}
http.Redirect(w, r, "/admin/grants?message="+url.QueryEscape("Grant revoked"), http.StatusFound)
}
// AdminClientClaims handles GET /admin/clients/:id/claims
func (h *AdminHandler) AdminClientClaims(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
clientID := ps.ByName("id")
existing, _ := h.clientStore.ListCustomClaims(r.Context(), clientID)
clientName, err := h.clientStore.GetClientName(r.Context(), clientID)
if err != nil {
http.Redirect(w, r, "/admin/clients?error="+url.QueryEscape("Client not found"), http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.AdminClientClaimsPage(nosurf.Token(r), clientID, clientName, existing,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// AdminClientClaimsPost handles POST /admin/clients/:id/claims
func (h *AdminHandler) AdminClientClaimsPost(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
clientID := ps.ByName("id")
adminID, ok := r.Context().Value(types.UserContextKey).(string)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
redirectBase := "/admin/clients/" + clientID + "/claims"
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Invalid form data"), http.StatusFound)
return
}
keys := r.Form["key[]"]
claimTypes := r.Form["type[]"]
values := r.Form["value[]"]
destinations := r.Form["destination[]"]
defs, err := validateClaims(keys, claimTypes, values, destinations)
if err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
if err := h.clientStore.SetCustomClaimsAdmin(r.Context(), clientID, defs); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to save claims"), http.StatusFound)
return
}
_ = h.auditStore.Log(r.Context(), adminID, postgres.AuditActionSetCustomClaims, "client", clientID, extractIP(r), r.UserAgent())
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Claims saved"), http.StatusFound)
}
// extractIP returns the client IP from the request, checking proxy headers first.
func extractIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.IndexByte(xff, ','); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
package handlers
import (
"encoding/json"
"math/big"
"net/http"
"strings"
"encoding/base64"
"github.com/iabhishekrajput/anekdote-auth/internal/crypto"
"github.com/julienschmidt/httprouter"
)
type DiscoveryHandler struct {
keyStore *crypto.KeyStore
appURL string
}
func NewDiscoveryHandler(ks *crypto.KeyStore, appURL string) *DiscoveryHandler {
return &DiscoveryHandler{
keyStore: ks,
appURL: strings.TrimRight(appURL, "/"),
}
}
// JWK represents a single JSON Web Key
type JWK struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Alg string `json:"alg"`
}
// JWKS represents the set of JSON Web Keys
type JWKS struct {
Keys []JWK `json:"keys"`
}
func (h *DiscoveryHandler) WellKnownJWKS(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
pubKey := h.keyStore.PublicKey
// Convert the RSA Exponent integer to bytes and base64url encode them
eBytes := big.NewInt(int64(pubKey.E)).Bytes()
eStr := base64.RawURLEncoding.EncodeToString(eBytes)
// Convert the RSA Modulus to bytes and base64url encode them
nStr := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes())
jwks := JWKS{
Keys: []JWK{
{
Kty: "RSA",
Kid: h.keyStore.KeyID,
Use: "sig",
N: nStr,
E: eStr,
Alg: "RS256",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jwks)
}
package handlers
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/big"
"net/http"
"regexp"
"time"
goredis "github.com/go-redis/redis/v8"
"github.com/iabhishekrajput/anekdote-auth/internal/config"
"github.com/iabhishekrajput/anekdote-auth/internal/mailer"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/iabhishekrajput/anekdote-auth/internal/web"
"github.com/iabhishekrajput/anekdote-auth/web/ui"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf"
"golang.org/x/crypto/bcrypt"
)
type IdentityHandler struct {
config *config.Config
userStore *postgres.UserStore
sessionStore *redis.SessionStore
mailer *mailer.Mailer
orgStore *postgres.OrgStore // optional; enables invite join on verify-email
rdb *goredis.Client // optional; required for invite Redis cleanup
bloom *redis.UsernameBloom // optional; populated after Create to keep filter warm
}
func NewIdentityHandler(cfg *config.Config, uStore *postgres.UserStore, sStore *redis.SessionStore, mailSvc *mailer.Mailer) *IdentityHandler {
return &IdentityHandler{
config: cfg,
userStore: uStore,
sessionStore: sStore,
mailer: mailSvc,
}
}
// WithOrgSupport enables the invite-join flow in RegisterFunc and VerifyEmailFunc.
func (h *IdentityHandler) WithOrgSupport(orgStore *postgres.OrgStore, rdb *goredis.Client) *IdentityHandler {
h.orgStore = orgStore
h.rdb = rdb
return h
}
// WithBloom attaches the username bloom filter so new registrations keep it warm.
func (h *IdentityHandler) WithBloom(b *redis.UsernameBloom) *IdentityHandler {
h.bloom = b
return h
}
func (h *IdentityHandler) render(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
if errStr := r.URL.Query().Get("error"); errStr != "" {
if _, exists := data["Error"]; !exists {
data["Error"] = errStr
}
}
if msgStr := r.URL.Query().Get("message"); msgStr != "" {
if _, exists := data["Success"]; !exists {
data["Success"] = msgStr
}
}
var errorMsg, successMsg string
if v, ok := data["Error"].(string); ok {
errorMsg = v
}
if v, ok := data["Success"].(string); ok {
successMsg = v
}
csrfToken := nosurf.Token(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch name {
case "register.tmpl":
inviteEmail, _ := data["InviteEmail"].(string)
inviteToken, _ := data["InviteToken"].(string)
usernameError, _ := data["UsernameError"].(string)
emailError, _ := data["EmailError"].(string)
component := ui.RegisterPage(csrfToken, inviteEmail, inviteToken, errorMsg, usernameError, emailError, successMsg)
_ = component.Render(r.Context(), w)
case "login.tmpl":
req, _ := data["Req"].(string)
email, _ := data["Email"].(string)
component := ui.LoginPage(csrfToken, req, email, errorMsg, successMsg)
_ = component.Render(r.Context(), w)
case "forgot_password.tmpl":
component := ui.ForgotPasswordPage(csrfToken, errorMsg, successMsg)
_ = component.Render(r.Context(), w)
case "reset_password.tmpl":
token, _ := data["Token"].(string)
component := ui.ResetPasswordPage(csrfToken, token, errorMsg, successMsg)
_ = component.Render(r.Context(), w)
case "verify_email.tmpl":
userID, _ := data["UserID"].(string)
component := ui.VerifyEmailPage(csrfToken, userID, errorMsg, successMsg)
_ = component.Render(r.Context(), w)
case "resend_verification.tmpl":
component := ui.ResendVerificationPage(csrfToken, errorMsg, successMsg)
_ = component.Render(r.Context(), w)
default:
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("template not found"))
}
}
func (h *IdentityHandler) RegisterFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if r.Method == http.MethodGet {
data := map[string]interface{}{}
if tok := r.URL.Query().Get("invite"); tok != "" && h.rdb != nil {
if raw, err := h.rdb.Get(r.Context(), "org:invite:"+tok).Result(); err == nil {
var inv struct {
Email string `json:"email"`
}
if json.Unmarshal([]byte(raw), &inv) == nil && inv.Email != "" {
data["InviteEmail"] = inv.Email
data["InviteToken"] = tok
}
}
}
h.render(w, r, "register.tmpl", data)
return
}
email := r.FormValue("email")
password := r.FormValue("password")
name := r.FormValue("name")
username := r.FormValue("username")
inviteToken := r.FormValue("invite_token")
inviteData := func(extra map[string]interface{}) map[string]interface{} {
if inviteToken != "" {
extra["InviteToken"] = inviteToken
extra["InviteEmail"] = email
}
return extra
}
if email == "" || password == "" {
w.WriteHeader(http.StatusBadRequest)
h.render(w, r, "register.tmpl", inviteData(map[string]interface{}{"Error": "Email and password required"}))
return
}
if username == "" {
w.WriteHeader(http.StatusBadRequest)
h.render(w, r, "register.tmpl", inviteData(map[string]interface{}{"UsernameError": "Username is required"}))
return
}
if err := validatePassword(password); err != nil {
w.WriteHeader(http.StatusBadRequest)
h.render(w, r, "register.tmpl", inviteData(map[string]interface{}{"Error": err.Error()}))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "register.tmpl", inviteData(map[string]interface{}{"Error": "Server Error"}))
return
}
user, err := h.userStore.Create(email, name, username, string(hash))
if err != nil {
w.WriteHeader(http.StatusConflict)
data := inviteData(map[string]interface{}{})
switch {
case errors.Is(err, postgres.ErrEmailTaken):
data["Error"] = "Email already registered"
data["EmailError"] = "Email already registered"
case errors.Is(err, postgres.ErrUsernameTaken):
data["Error"] = "Username already taken — choose a different one"
data["UsernameError"] = "Username already taken — choose a different one"
default:
data["Error"] = "Error creating user"
}
h.render(w, r, "register.tmpl", data)
return
}
if h.bloom != nil {
_ = h.bloom.Add(context.Background(), username)
}
// Generate 6-digit OTP
otp, _ := generateOTP()
if h.mailer != nil {
_ = h.sessionStore.CreateOTP(context.Background(), user.ID, otp)
if err := h.mailer.SendOTP(context.Background(), user.Email, otp); err != nil {
slog.Error("failed to send OTP email on register", "email", user.Email, "err", err)
}
} else {
_ = h.sessionStore.CreateOTP(context.Background(), user.ID, otp)
slog.Debug("OTP generated (no mailer configured)", "email", email, "otp", otp)
}
// If this registration came from an invite link, store the token so
// VerifyEmailFunc can complete the org join after OTP verification.
// inviteToken comes from the hidden form field (the URL query param is lost on POST).
if inviteToken != "" && h.orgStore != nil {
_ = h.sessionStore.SetPendingInvite(context.Background(), user.ID, inviteToken)
}
slog.Info("register success", "email", user.Email, "user_id", user.ID, "remote", r.RemoteAddr)
http.Redirect(w, r, "/verify-email?user_id="+user.ID, http.StatusFound)
}
// Helper to generate a 6-digit cryptographic OTP
func generateOTP() (string, error) {
max := big.NewInt(1000000)
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// validatePassword checks if a password meets complexity requirements
func validatePassword(password string) error {
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one lowercase letter")
}
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one uppercase letter")
}
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
return fmt.Errorf("password must contain at least one number")
}
if !regexp.MustCompile(`[!@#~$%^&*(),.?":{}|<>]`).MatchString(password) {
return fmt.Errorf("password must contain at least one special character")
}
return nil
}
func (h *IdentityHandler) VerifyEmailFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if r.Method == http.MethodGet {
userID := r.URL.Query().Get("user_id")
h.render(w, r, "verify_email.tmpl", map[string]interface{}{
"UserID": userID,
})
return
}
userIDStr := r.FormValue("user_id")
otp := r.FormValue("otp")
if userIDStr == "" || otp == "" {
w.WriteHeader(http.StatusBadRequest)
h.render(w, r, "verify_email.tmpl", map[string]interface{}{"Error": "User ID and OTP required", "UserID": userIDStr})
return
}
userID := userIDStr
valid, verifyErr := h.sessionStore.VerifyOTP(context.Background(), userID, otp)
if verifyErr != nil || !valid {
w.WriteHeader(http.StatusUnauthorized)
h.render(w, r, "verify_email.tmpl", map[string]interface{}{"Error": "Invalid or expired OTP", "UserID": userIDStr})
return
}
// Update the database to mark user as verified
err := h.userStore.UpdateVerified(userID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "verify_email.tmpl", map[string]interface{}{"Error": "Failed to update user status", "UserID": userIDStr})
return
}
// Automatically log the user in by creating a session
sessionID, err := h.sessionStore.Create(context.Background(), userID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "login.tmpl", map[string]interface{}{"Error": "Verified but failed to create session. Please login."})
return
}
http.SetCookie(w, &http.Cookie{
Name: "auth_session",
Value: sessionID,
Path: "/",
Expires: time.Now().Add(24 * time.Hour),
HttpOnly: true,
Secure: h.config.AppEnv == "production",
SameSite: http.SameSiteLaxMode,
})
// Complete org invite join if there's a pending invite token for this user.
if h.orgStore != nil && h.rdb != nil {
if inviteToken, err := h.sessionStore.GetAndDeletePendingInvite(context.Background(), userID); err == nil && inviteToken != "" {
if raw, err := h.rdb.Get(context.Background(), "org:invite:"+inviteToken).Result(); err == nil {
var inv struct {
OrgID string `json:"org_id"`
Role string `json:"role"`
}
if json.Unmarshal([]byte(raw), &inv) == nil && inv.OrgID != "" {
if addErr := h.orgStore.AddMember(context.Background(), inv.OrgID, userID, inv.Role, nil); addErr == nil {
h.rdb.Del(context.Background(), "org:invite:"+inviteToken)
h.rdb.SRem(context.Background(), "org:invites:"+inv.OrgID, inviteToken)
slog.Info("email verified", "user_id", userID, "remote", r.RemoteAddr)
http.Redirect(w, r, "/account/orgs?message=You+joined+the+organization", http.StatusFound)
return
}
}
}
// Invite expired or invalid — verification still succeeded, just go to /account
}
}
slog.Info("email verified", "user_id", userID, "remote", r.RemoteAddr)
http.Redirect(w, r, "/account", http.StatusFound)
}
func (h *IdentityHandler) LoginFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if r.Method == http.MethodGet {
h.render(w, r, "login.tmpl", map[string]interface{}{
"Req": r.URL.Query().Get("req"),
"Email": r.URL.Query().Get("email"),
})
return
}
email := r.FormValue("email")
password := r.FormValue("password")
oauthReq := r.FormValue("req") // Originating OAuth request URL
fails, _ := h.sessionStore.GetFailedLogin(context.Background(), email)
if fails >= 5 {
slog.Warn("login blocked: too many failed attempts", "email", email, "remote", r.RemoteAddr)
w.WriteHeader(http.StatusTooManyRequests)
h.render(w, r, "login.tmpl", map[string]interface{}{"Error": "Account locked due to too many failed attempts. Try again in 15 minutes.", "Req": oauthReq})
return
}
user, err := h.userStore.GetByEmail(email)
if err != nil {
h.sessionStore.IncrementFailedLogin(context.Background(), email)
slog.Warn("login failed: user not found", "email", email, "remote", r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
h.render(w, r, "login.tmpl", map[string]interface{}{"Error": "Invalid credentials", "Req": oauthReq})
return
}
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
if err != nil {
h.sessionStore.IncrementFailedLogin(context.Background(), email)
slog.Warn("login failed: invalid password", "email", email, "remote", r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
h.render(w, r, "login.tmpl", map[string]interface{}{"Error": "Invalid credentials", "Req": oauthReq})
return
}
h.sessionStore.ResetFailedLogin(context.Background(), email)
if user.DisabledAt != nil {
slog.Warn("login blocked: account disabled", "email", user.Email, "user_id", user.ID, "remote", r.RemoteAddr)
w.WriteHeader(http.StatusForbidden)
h.render(w, r, "login.tmpl", map[string]interface{}{
"Error": "Your account has been disabled. Contact your administrator.",
"Req": oauthReq,
})
return
}
if !user.IsVerified {
otp, _ := generateOTP()
_ = h.sessionStore.CreateOTP(context.Background(), user.ID, otp)
if h.mailer != nil {
if err := h.mailer.SendOTP(context.Background(), user.Email, otp); err != nil {
slog.Error("failed to send OTP email on login", "email", user.Email, "err", err)
}
} else {
slog.Debug("OTP generated (no mailer configured)", "email", user.Email, "otp", otp)
}
http.Redirect(w, r, "/verify-email?user_id="+user.ID+"&message=A+new+verification+code+has+been+sent+to+your+email.", http.StatusFound)
return
}
// Create Session in Redis
sessionID, err := h.sessionStore.Create(context.Background(), user.ID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "login.tmpl", map[string]interface{}{"Error": "Server Error", "Req": oauthReq})
return
}
// Set Cookie
http.SetCookie(w, &http.Cookie{
Name: "auth_session",
Value: sessionID,
Path: "/",
Expires: time.Now().Add(24 * time.Hour),
HttpOnly: true,
Secure: h.config.AppEnv == "production",
SameSite: http.SameSiteLaxMode,
})
slog.Info("login success", "email", user.Email, "user_id", user.ID, "remote", r.RemoteAddr)
// Redirect back to local Authorization flow if it exists.
if web.IsSafeLocalRedirect(oauthReq) {
http.Redirect(w, r, oauthReq, http.StatusFound)
return
}
http.Redirect(w, r, "/account", http.StatusFound)
}
func (h *IdentityHandler) LogoutFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cookie, err := r.Cookie("auth_session")
if err == nil && cookie.Value != "" {
_ = h.sessionStore.Delete(context.Background(), cookie.Value)
}
slog.Info("logout", "remote", r.RemoteAddr)
// Clear the cookie in the browser
web.ClearSessionCookie(w, r)
redirectTo := "/login"
if next := r.FormValue("redirect_to"); web.IsSafeLocalRedirect(next) {
redirectTo = next
}
http.Redirect(w, r, redirectTo, http.StatusFound)
}
func (h *IdentityHandler) ForgotPasswordFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if r.Method == http.MethodGet {
h.render(w, r, "forgot_password.tmpl", nil)
return
}
email := r.FormValue("email")
user, err := h.userStore.GetByEmail(email)
if err != nil {
// Do not reveal if email exists or not to prevent enumeration
h.render(w, r, "forgot_password.tmpl", map[string]interface{}{"Success": "If your email is registered, you will receive a reset link shortly."})
return
}
resetToken, err := h.sessionStore.CreateResetToken(context.Background(), user.ID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "forgot_password.tmpl", map[string]interface{}{"Error": "Error generating token"})
return
}
resetLink := "http://" + r.Host + "/reset-password?token=" + resetToken
if h.mailer != nil {
err = h.mailer.SendPasswordReset(context.Background(), user.Email, resetLink)
if err != nil {
slog.Error("failed to send password reset email", "email", user.Email, "err", err)
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "forgot_password.tmpl", map[string]interface{}{"Error": "Failed to dispatch email"})
return
}
} else {
slog.Debug("password reset link generated (no mailer configured)", "email", user.Email, "link", resetLink)
h.render(w, r, "forgot_password.tmpl", map[string]interface{}{
"Success": "Reset link generated (check logs/console).",
})
return
}
slog.Info("password reset email sent", "email", user.Email, "remote", r.RemoteAddr)
h.render(w, r, "forgot_password.tmpl", map[string]interface{}{"Success": "Reset link dispatched! Please check your email inbox."})
}
func (h *IdentityHandler) ResetPasswordFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
token := r.URL.Query().Get("token")
if token == "" {
token = r.FormValue("token") // Try Post body
}
if r.Method == http.MethodGet {
h.render(w, r, "reset_password.tmpl", map[string]interface{}{
"Token": token,
})
return
}
password := r.FormValue("password")
if token == "" || password == "" {
w.WriteHeader(http.StatusBadRequest)
h.render(w, r, "reset_password.tmpl", map[string]interface{}{"Error": "Missing inputs", "Token": token})
return
}
if err := validatePassword(password); err != nil {
w.WriteHeader(http.StatusBadRequest)
h.render(w, r, "reset_password.tmpl", map[string]interface{}{"Error": err.Error(), "Token": token})
return
}
userID, err := h.sessionStore.GetUserByResetToken(context.Background(), token)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
h.render(w, r, "reset_password.tmpl", map[string]interface{}{"Error": "Invalid or expired token", "Token": token})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "reset_password.tmpl", map[string]interface{}{"Error": "Server Error", "Token": token})
return
}
err = h.userStore.UpdatePassword(userID, string(hash))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "reset_password.tmpl", map[string]interface{}{"Error": "Failed to update password", "Token": token})
return
}
// Invalidate the token so it can't be reused
h.sessionStore.DeleteResetToken(context.Background(), token)
slog.Info("password reset success", "user_id", userID, "remote", r.RemoteAddr)
h.render(w, r, "login.tmpl", map[string]interface{}{"Success": "Password updated successfully! Please login."})
}
// ResendOTPFunc handles POST /verify-email/resend.
// Generates a fresh OTP for the given user_id and emails it.
func (h *IdentityHandler) ResendOTPFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userIDStr := r.FormValue("user_id")
if userIDStr == "" {
http.Redirect(w, r, "/resend-verification", http.StatusFound)
return
}
user, err := h.userStore.GetByID(userIDStr)
if err != nil || user.IsVerified {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
otp, _ := generateOTP()
_ = h.sessionStore.CreateOTP(context.Background(), userIDStr, otp)
if h.mailer != nil {
if err := h.mailer.SendOTP(context.Background(), user.Email, otp); err != nil {
slog.Error("failed to send OTP email on verify-email resend", "email", user.Email, "err", err)
}
} else {
slog.Debug("OTP generated (no mailer configured)", "email", user.Email, "otp", otp)
}
http.Redirect(w, r, "/verify-email?user_id="+userIDStr+"&message=A+new+verification+code+has+been+sent+to+your+email.", http.StatusFound)
}
// ResendVerificationFunc handles GET/POST /resend-verification.
// GET renders a form asking for the user's email.
// POST looks up the account, and if it exists and is unverified, sends a fresh OTP.
func (h *IdentityHandler) ResendVerificationFunc(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if r.Method == http.MethodGet {
h.render(w, r, "resend_verification.tmpl", nil)
return
}
email := r.FormValue("email")
user, err := h.userStore.GetByEmail(email)
if err != nil || user.IsVerified {
// Do not reveal whether email exists or is already verified.
h.render(w, r, "resend_verification.tmpl", map[string]interface{}{
"Success": "If that email belongs to an unverified account, a new code has been sent.",
})
return
}
otp, _ := generateOTP()
_ = h.sessionStore.CreateOTP(context.Background(), user.ID, otp)
if h.mailer != nil {
if err := h.mailer.SendOTP(context.Background(), user.Email, otp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.render(w, r, "resend_verification.tmpl", map[string]interface{}{"Error": "Failed to send email. Please try again."})
return
}
} else {
slog.Debug("OTP generated (no mailer configured)", "email", user.Email, "otp", otp)
}
http.Redirect(w, r, "/verify-email?user_id="+user.ID+"&message=A+new+verification+code+has+been+sent+to+your+email.", http.StatusFound)
}
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
goredis "github.com/go-redis/redis/v8"
"github.com/golang-jwt/jwt/v5"
"github.com/iabhishekrajput/anekdote-auth/internal/crypto"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/julienschmidt/httprouter"
)
// ManagementRevStore checks JWT revocation for Management API bearer tokens.
type ManagementRevStore interface {
IsRevoked(ctx context.Context, jti string) (bool, error)
RevokeJTI(ctx context.Context, jti string, duration time.Duration) error
}
// ManagementClientStore is the minimal store interface the Management API needs.
type ManagementClientStore interface {
GetClientOrgID(ctx context.Context, clientID string) (*string, error)
ListCustomClaims(ctx context.Context, clientID string) ([]postgres.ClaimDefinition, error)
SetCustomClaimsAdmin(ctx context.Context, clientID string, defs []postgres.ClaimDefinition) error
PatchCustomClaimAdmin(ctx context.Context, clientID string, def postgres.ClaimDefinition) error
}
// ManagementHandler serves the Management REST API (Bearer JWT, management audience).
type ManagementHandler struct {
keyStore *crypto.KeyStore
revStore ManagementRevStore
clientStore ManagementClientStore
mgmtAud string // required aud claim value
appURL string // required iss claim value
rdb *goredis.Client
}
// WithTokenIndex enables blocklisting outstanding access tokens after claim updates.
func (h *ManagementHandler) WithTokenIndex(rdb *goredis.Client) *ManagementHandler {
h.rdb = rdb
return h
}
func NewManagementHandler(
keyStore *crypto.KeyStore,
revStore ManagementRevStore,
clientStore ManagementClientStore,
mgmtAud string,
) *ManagementHandler {
return &ManagementHandler{
keyStore: keyStore,
revStore: revStore,
clientStore: clientStore,
mgmtAud: mgmtAud,
}
}
// WithIssuer sets the expected iss claim value for token validation.
func (h *ManagementHandler) WithIssuer(issuer string) *ManagementHandler {
h.appURL = issuer
return h
}
// managementClaimInput is the JSON shape accepted by PUT /api/v1/clients/:id/claims.
type managementClaimInput struct {
Key string `json:"key"`
Type string `json:"type"`
Value string `json:"value"`
Destinations string `json:"destinations,omitempty"`
ScopeGate string `json:"scope_gate,omitempty"`
SourceKind string `json:"source_kind,omitempty"`
}
// managementClaimOutput is the JSON shape returned by GET /api/v1/clients/:id/claims.
// Uses "type" (not "value_type") so GET responses can be round-tripped directly as PUT inputs.
type managementClaimOutput struct {
Key string `json:"key"`
Type string `json:"type"`
Value string `json:"value"`
Destinations string `json:"destinations"`
ScopeGate string `json:"scope_gate,omitempty"`
SourceKind string `json:"source_kind"`
}
// GetClientClaims handles GET /api/v1/clients/:id/claims.
func (h *ManagementHandler) GetClientClaims(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
claims, err := h.verifyManagementToken(r, "read:client_claims")
if err != nil {
h.writeAuthError(w, err)
return
}
clientID := ps.ByName("id")
if err := h.checkOwnership(r.Context(), claims, clientID); err != nil {
if errors.Is(err, errOwnershipDenied) {
tokenOrgID, _ := claims["org_id"].(string)
if tokenOrgID == "" {
h.writeError(w, http.StatusForbidden, "management API requires a service account token with org_id claim")
} else {
h.writeError(w, http.StatusForbidden, "access denied")
}
} else {
// Return 403 (not 404) to prevent client ID enumeration.
h.writeError(w, http.StatusForbidden, "access denied")
}
return
}
defs, err := h.clientStore.ListCustomClaims(r.Context(), clientID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to load claims")
return
}
out := managementDefsToOutput(defs)
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(map[string]any{"claims": out})
}
// PutClientClaims handles PUT /api/v1/clients/:id/claims.
func (h *ManagementHandler) PutClientClaims(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
claims, err := h.verifyManagementToken(r, "update:client_claims")
if err != nil {
h.writeAuthError(w, err)
return
}
clientID := ps.ByName("id")
if err := h.checkOwnership(r.Context(), claims, clientID); err != nil {
if errors.Is(err, errOwnershipDenied) {
tokenOrgID, _ := claims["org_id"].(string)
if tokenOrgID == "" {
h.writeError(w, http.StatusForbidden, "management API requires a service account token with org_id claim")
} else {
h.writeError(w, http.StatusForbidden, "access denied")
}
} else {
// Return 403 (not 404) to prevent client ID enumeration.
h.writeError(w, http.StatusForbidden, "access denied")
}
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
var body struct {
Claims []managementClaimInput `json:"claims"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
return
}
defs, err := validateManagementClaims(body.Claims)
if err != nil {
h.writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
if err := h.clientStore.SetCustomClaimsAdmin(r.Context(), clientID, defs); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save claims")
return
}
h.blocklistClientTokens(r.Context(), clientID)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(map[string]any{
"operation": "replace_all",
"count": len(defs),
"claims": managementDefsToOutput(defs),
})
}
// PatchClientClaim handles PATCH /api/v1/clients/:id/claims/:key.
func (h *ManagementHandler) PatchClientClaim(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
claims, err := h.verifyManagementToken(r, "update:client_claims")
if err != nil {
h.writeAuthError(w, err)
return
}
clientID := ps.ByName("id")
if err := h.checkOwnership(r.Context(), claims, clientID); err != nil {
if errors.Is(err, errOwnershipDenied) {
tokenOrgID, _ := claims["org_id"].(string)
if tokenOrgID == "" {
h.writeError(w, http.StatusForbidden, "management API requires a service account token with org_id claim")
} else {
h.writeError(w, http.StatusForbidden, "access denied")
}
} else {
h.writeError(w, http.StatusForbidden, "access denied")
}
return
}
key := strings.TrimSpace(strings.TrimPrefix(ps.ByName("key"), "/"))
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
var body managementClaimInput
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
return
}
if strings.TrimSpace(body.Key) == "" {
body.Key = key
}
if body.Key != key {
h.writeError(w, http.StatusUnprocessableEntity, "claim key in path and body must match")
return
}
defs, err := validateManagementClaims([]managementClaimInput{body})
if err != nil {
h.writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
if len(defs) != 1 {
h.writeError(w, http.StatusUnprocessableEntity, "claim key is required")
return
}
if err := h.clientStore.PatchCustomClaimAdmin(r.Context(), clientID, defs[0]); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save claim")
return
}
h.blocklistClientTokens(r.Context(), clientID)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(map[string]any{
"operation": "patch",
"claim": managementDefsToOutput(defs)[0],
})
}
func (h *ManagementHandler) blocklistClientTokens(ctx context.Context, clientID string) {
if h.rdb == nil || h.revStore == nil {
return
}
key := "oauth:client-tokens:" + clientID
jtis, err := h.rdb.SMembers(ctx, key).Result()
if err != nil {
return
}
for _, jti := range jtis {
_ = h.revStore.RevokeJTI(ctx, jti, 2*time.Hour)
}
_ = h.rdb.Del(ctx, key).Err()
}
var errOwnershipDenied = errors.New("ownership denied")
// verifyManagementToken parses and validates the Bearer JWT, checks the management
// audience, revocation state, and required scope. Returns the token claims on success.
func (h *ManagementHandler) verifyManagementToken(r *http.Request, requiredScope string) (jwt.MapClaims, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") || len(authHeader) == len("Bearer ") {
return nil, errors.New("missing or malformed Authorization header")
}
tokenStr := authHeader[len("Bearer "):]
parsed, err := jwt.ParseWithClaims(tokenStr, jwt.MapClaims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, errors.New("unexpected signing method")
}
kid, _ := token.Header["kid"].(string)
if kid != h.keyStore.KeyID {
return nil, errors.New("unknown kid")
}
return h.keyStore.PublicKey, nil
},
jwt.WithValidMethods([]string{"RS256"}),
)
if err != nil || !parsed.Valid {
if err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "expired"):
return nil, errors.New("token_expired")
case strings.Contains(msg, "unknown kid"):
return nil, errors.New("unknown_kid")
case strings.Contains(msg, "unexpected signing method"):
return nil, errors.New("invalid_signature")
}
}
return nil, errors.New("invalid token")
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid claims")
}
// Issuer must match the server's own URL.
if h.appURL != "" {
iss, _ := claims["iss"].(string)
if iss != h.appURL {
return nil, errors.New("invalid token")
}
}
// Audience must exactly match the management audience (string or single-element array).
var aud string
switch v := claims["aud"].(type) {
case string:
aud = v
case []interface{}:
if len(v) == 1 {
aud, _ = v[0].(string)
}
}
if aud != h.mgmtAud {
return nil, errors.New("invalid token")
}
// Check revocation (fail closed).
jti, _ := claims["jti"].(string)
if jti == "" {
return nil, errors.New("missing jti")
}
revoked, revErr := h.revStore.IsRevoked(r.Context(), jti)
if revErr != nil || revoked {
return nil, errors.New("token revoked")
}
// Scope check — opaque error to avoid leaking scope names.
scope, _ := claims["scope"].(string)
if !scopeHasWord(scope, requiredScope) {
return nil, errors.New("insufficient scope")
}
return claims, nil
}
// checkOwnership verifies the token's org_id matches the client's org_id.
func (h *ManagementHandler) checkOwnership(ctx context.Context, claims jwt.MapClaims, clientID string) error {
tokenOrgID, _ := claims["org_id"].(string)
if tokenOrgID == "" {
return errOwnershipDenied
}
clientOrgID, err := h.clientStore.GetClientOrgID(ctx, clientID)
if err != nil || clientOrgID == nil {
return errors.New("client not found")
}
if *clientOrgID != tokenOrgID {
return errOwnershipDenied
}
return nil
}
// validateManagementClaims validates claim definitions received from the Management API.
// Unlike validateClaims (which handles form arrays), this accepts the JSON struct slice
// and also allows scope_gate (Management API is the intended DX for scope-gated claims).
func validateManagementClaims(inputs []managementClaimInput) ([]postgres.ClaimDefinition, error) {
if len(inputs) > 20 {
return nil, errors.New("maximum 20 claims per client")
}
defs := make([]postgres.ClaimDefinition, 0, len(inputs))
seen := make(map[string]bool, len(inputs))
approxSize := 2
for _, inp := range inputs {
k := strings.TrimSpace(inp.Key)
if k == "" {
continue
}
if len(k) > 100 {
return nil, errors.New("claim key must be 100 characters or fewer")
}
if !claimKeyRegex.MatchString(k) {
return nil, errors.New("claim key \"" + k + "\" contains invalid characters")
}
if _, reserved := reservedClaimKeys[strings.ToLower(k)]; reserved {
return nil, errors.New("\"" + k + "\" is a reserved claim name and cannot be overridden")
}
if seen[k] {
return nil, errors.New("duplicate claim key \"" + k + "\"")
}
seen[k] = true
rawVal := strings.TrimSpace(inp.Value)
var valueType, storedVal string
switch inp.Type {
case "string":
valueType, storedVal = "string", rawVal
approxSize += len(k) + len(rawVal) + 6
case "number":
lower := strings.ToLower(rawVal)
if lower == "nan" || lower == "inf" || lower == "+inf" || lower == "-inf" || lower == "infinity" || lower == "-infinity" {
return nil, errors.New("claim \"" + k + "\": number value must be finite")
}
var f float64
if _, err := fmt.Sscanf(rawVal, "%g", &f); err != nil {
return nil, errors.New("claim \"" + k + "\": invalid number value")
}
valueType, storedVal = "number", fmt.Sprintf("%g", f)
approxSize += len(k) + len(storedVal) + 4
case "boolean":
if rawVal != "true" && rawVal != "false" {
return nil, errors.New("claim \"" + k + "\": boolean value must be \"true\" or \"false\"")
}
valueType, storedVal = "boolean", rawVal
approxSize += len(k) + 7
default:
return nil, errors.New("claim \"" + k + "\": type must be string, number, or boolean")
}
if approxSize > 4096 {
return nil, errors.New("total claim payload too large (approx 4 KB max)")
}
sourceKind := strings.TrimSpace(inp.SourceKind)
if sourceKind == "" {
sourceKind = "static"
}
if sourceKind != "static" && sourceKind != "user_attribute" && sourceKind != "expression" {
return nil, errors.New("claim \"" + k + "\": source_kind must be static, user_attribute, or expression")
}
if sourceKind == "user_attribute" && !validClaimAttribute[storedVal] {
return nil, errors.New("claim \"" + k + "\": unsupported user_attribute value")
}
dest := strings.TrimSpace(inp.Destinations)
if dest == "" {
dest = "token"
}
dest = normalizeDestinationsHandler(dest)
if !validDestinations[dest] {
return nil, errors.New("claim \"" + k + "\": invalid destinations value \"" + dest + "\"")
}
scopeGate := strings.TrimSpace(inp.ScopeGate)
if scopeGate != "" {
if len(scopeGate) > 64 {
return nil, errors.New("claim \"" + k + "\": scope_gate must be 64 characters or fewer")
}
if strings.ContainsAny(scopeGate, " \t\n\r") {
return nil, errors.New("claim \"" + k + "\": scope_gate must be a single scope identifier (no spaces)")
}
}
defs = append(defs, postgres.ClaimDefinition{
Key: k,
ValueType: valueType,
Value: storedVal,
Destinations: dest,
ScopeGate: scopeGate,
SourceKind: sourceKind,
})
}
return defs, nil
}
func managementDefsToOutput(defs []postgres.ClaimDefinition) []managementClaimOutput {
out := make([]managementClaimOutput, len(defs))
for i, d := range defs {
out[i] = managementClaimOutput{
Key: d.Key,
Type: d.ValueType,
Value: d.Value,
Destinations: d.Destinations,
ScopeGate: d.ScopeGate,
SourceKind: d.SourceKind,
}
if out[i].SourceKind == "" {
out[i].SourceKind = "static"
}
}
return out
}
var validClaimAttribute = map[string]bool{
"user.id": true,
"user.email": true,
"user.name": true,
"user.username": true,
"org.id": true,
"org.role": true,
}
func (h *ManagementHandler) writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// writeAuthError returns a 401 with RFC 6750 error fields. The error detail from
// verifyManagementToken is mapped to opaque codes so internal details don't leak.
func (h *ManagementHandler) writeAuthError(w http.ResponseWriter, err error) {
msg := err.Error()
code := "invalid_token"
desc := "token validation failed"
switch msg {
case "missing or malformed Authorization header":
// RFC 6750 §3.1: realm-only challenge when no credentials are present; no error= parameter.
w.Header().Set("WWW-Authenticate", `Bearer realm="anekdote-auth"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "invalid_request",
"error_description": "Authorization header is required",
})
return
case "token revoked":
desc = "token has been revoked"
case "token_expired":
desc = "token has expired"
case "unknown_kid":
desc = "token header references an unknown key id"
case "invalid_signature":
desc = "token signature algorithm is invalid"
case "missing jti":
desc = "token validation failed"
case "insufficient scope":
code = "insufficient_scope"
desc = "token does not have the required scope"
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s" error_description="%s"`, code, desc))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
body := map[string]string{
"error": code,
"error_description": desc,
}
if msg == "token_expired" || msg == "unknown_kid" || msg == "invalid_signature" {
body["error_subcode"] = msg
}
json.NewEncoder(w).Encode(body)
}
// scopeHasWord reports whether scope contains the exact word target.
func scopeHasWord(scope, target string) bool {
return strings.Contains(" "+scope+" ", " "+target+" ")
}
// normalizeDestinationsHandler sorts comma-separated destination parts alphabetically
// so that "id_token,access_token" and "access_token,id_token" resolve to the same canonical form.
func normalizeDestinationsHandler(d string) string {
parts := strings.Split(d, ",")
trimmed := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
trimmed = append(trimmed, s)
}
}
// simple insertion sort (always ≤5 elements)
for i := 1; i < len(trimmed); i++ {
for j := i; j > 0 && trimmed[j] < trimmed[j-1]; j-- {
trimmed[j], trimmed[j-1] = trimmed[j-1], trimmed[j]
}
}
return strings.Join(trimmed, ",")
}
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
oautherrors "github.com/go-oauth2/oauth2/v4/errors"
"github.com/go-oauth2/oauth2/v4/server"
"github.com/golang-jwt/jwt/v5"
"github.com/iabhishekrajput/anekdote-auth/internal/auth"
"github.com/iabhishekrajput/anekdote-auth/internal/crypto"
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/iabhishekrajput/anekdote-auth/web/ui"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf"
)
// authCodeTTL mirrors the OAuth2 server's authorization code expiry (server.go).
// The nonce is keyed by auth code and must not outlive it.
const authCodeTTL = 10 * time.Minute
// oauth2OrgStore extends OrgMembershipReader with org name lookup for the access-denied page.
type oauth2OrgStore interface {
auth.OrgMembershipReader
GetOrgByID(ctx context.Context, id string) (*models.Org, error)
}
// oauth2ClientGrantStore fetches multi-org grants for a client.
type oauth2ClientGrantStore interface {
ListUserEligibleOrgsForClient(ctx context.Context, clientID string, userID string) ([]*postgres.ClientGrantItem, error)
}
// IDTokenGenerator generates OIDC ID tokens for successful authorization_code exchanges.
// Implemented by *auth.JWTGenerator.
type IDTokenGenerator interface {
GenerateIDToken(ctx context.Context, sub, aud, scope, accessToken string, expiry time.Duration, nonce string) (string, error)
}
// OAuth2RevocationStore handles JWT revocation.
type OAuth2RevocationStore interface {
RevokeJTI(ctx context.Context, jti string, duration time.Duration) error
IsRevoked(ctx context.Context, jti string) (bool, error)
}
// OAuth2NonceStore handles OIDC nonce binding.
type OAuth2NonceStore interface {
StoreNonce(ctx context.Context, code, nonce string, ttl time.Duration) error
ConsumeNonce(ctx context.Context, code string) (string, error)
}
type OAuth2Handler struct {
server *server.Server
sessionStore *redis.SessionStore
revocStore OAuth2RevocationStore
nonceStore OAuth2NonceStore
keyStore *crypto.KeyStore
orgStore oauth2OrgStore // optional; enables org membership check and friendly denial page at /authorize
grantStore oauth2ClientGrantStore // optional; enables multi-org client grants
idTokenGen IDTokenGenerator // optional; enables id_token in /token response when openid scope granted
}
func NewOAuth2Handler(srv *server.Server, sess *redis.SessionStore, rev OAuth2RevocationStore, keys *crypto.KeyStore, orgStore oauth2OrgStore, grantStore oauth2ClientGrantStore, idTokenGen IDTokenGenerator) *OAuth2Handler {
h := &OAuth2Handler{
server: srv,
sessionStore: sess,
revocStore: rev,
keyStore: keys,
orgStore: orgStore,
grantStore: grantStore,
idTokenGen: idTokenGen,
}
if ns, ok := rev.(OAuth2NonceStore); ok {
h.nonceStore = ns
}
h.server.SetUserAuthorizationHandler(h.userAuthorizeHandler)
return h
}
func (h *OAuth2Handler) WithNonceStore(store OAuth2NonceStore) *OAuth2Handler {
h.nonceStore = store
return h
}
// responseCapture buffers the status code and body from go-oauth2 so we can
// inject id_token before the response reaches the client.
type responseCapture struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
}
func (rc *responseCapture) WriteHeader(code int) {
rc.statusCode = code
}
func (rc *responseCapture) Write(b []byte) (int, error) {
if rc.statusCode == 0 {
rc.statusCode = http.StatusOK
}
return rc.body.Write(b)
}
func (rc *responseCapture) flush() {
if rc.statusCode != 0 {
// Remove Content-Length so net/http recomputes it after body modification.
rc.ResponseWriter.Header().Del("Content-Length")
rc.ResponseWriter.WriteHeader(rc.statusCode)
}
rc.ResponseWriter.Write(rc.body.Bytes()) //nolint:errcheck
}
// authCodeCapture buffers the authorize response so we can persist the OIDC nonce
// before the redirect reaches the client, eliminating any store-before-redirect race.
type authCodeCapture struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
code string
}
func (ac *authCodeCapture) WriteHeader(status int) {
ac.statusCode = status
if status == http.StatusFound {
loc := ac.ResponseWriter.Header().Get("Location")
if u, err := url.Parse(loc); err == nil {
ac.code = u.Query().Get("code")
}
}
}
func (ac *authCodeCapture) Write(b []byte) (int, error) {
if ac.statusCode == 0 {
ac.statusCode = http.StatusOK
}
return ac.body.Write(b)
}
func (ac *authCodeCapture) flush() {
if ac.statusCode != 0 {
ac.ResponseWriter.Header().Del("Content-Length")
ac.ResponseWriter.WriteHeader(ac.statusCode)
}
ac.ResponseWriter.Write(ac.body.Bytes()) //nolint:errcheck
}
// Authorize handles the initial redirect from the client
func (h *OAuth2Handler) Authorize(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
// 1. Check if the user is logged in
userID, err := h.sessionStore.GetUserFromSession(r)
if err != nil || userID == "" {
// Store the current URL to redirect back after login
loginURL := "/login?req=" + url.QueryEscape(r.URL.String())
http.Redirect(w, r, loginURL, http.StatusFound)
return
}
// 2. Parse the request form so go-oauth2 can process both URL query params and POST values
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
nonce := r.FormValue("nonce")
ac := &authCodeCapture{ResponseWriter: w}
err = h.server.HandleAuthorizeRequest(ac, r)
if err != nil {
slog.Error("Authorize Request Error", "error", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Store nonce BEFORE flushing the redirect. This eliminates the race where a fast
// client exchanges the code before the nonce is persisted.
if nonce != "" && ac.code != "" && h.nonceStore != nil {
if storeErr := h.nonceStore.StoreNonce(r.Context(), ac.code, nonce, authCodeTTL); storeErr != nil {
slog.Error("authorize: failed to store nonce; aborting redirect", "error", storeErr)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
}
ac.flush()
}
func (h *OAuth2Handler) userAuthorizeHandler(w http.ResponseWriter, r *http.Request) (userID string, err error) {
// 1. Double check user is logged in
uid, err := h.sessionStore.GetUserFromSession(r)
if err != nil || uid == "" {
http.Redirect(w, r, "/login?req="+url.QueryEscape(r.URL.String()), http.StatusFound)
return "", nil // returning empty userID stops go-oauth2 processing
}
clientID := r.FormValue("client_id")
if clientID == "" {
clientID = "Unknown Application"
}
// 2. Handle Consent Form Submission (POST with accept/reject).
if r.Method == http.MethodPost {
if r.FormValue("accept") != "true" {
return "", oautherrors.ErrAccessDenied
}
// If a specific org was selected from the picker, encode it in the returned userID.
// The JWT generator will split on "|" and validate membership at token time.
if selectedOrg := r.FormValue("selected_org_id"); selectedOrg != "" {
return uid + "|" + selectedOrg, nil
}
return uid, nil
}
// 3. Fetch client once — used for org gate, domain display, and name on consent screen.
type clientInfoIface interface {
GetDomain() string
GetName() string
}
var fetchedClient clientInfoIface
if clientID != "Unknown Application" {
if c, cErr := h.server.Manager.GetClient(r.Context(), clientID); cErr == nil && c != nil {
if ci, ok := c.(clientInfoIface); ok {
fetchedClient = ci
}
}
}
// Resolve display name early so access-denied pages can use it.
clientName := clientID
if fetchedClient != nil {
if n := fetchedClient.GetName(); n != "" {
clientName = n
}
}
// 4. Determine eligible orgs:
// - Legacy single-org client (OrgID set): use existing single-org membership check.
// - Multi-org client (OrgID nil, grantStore set): query client_org_grants.
returnURL := r.URL.Query().Get("redirect_uri")
var eligibleOrgs []ui.OrgOption
var singleOrgID *string // set when exactly 1 org matches
if h.orgStore != nil && fetchedClient != nil {
if oci, ok := fetchedClient.(*postgres.OrgClientInfo); ok {
if oci.OrgID != nil {
// Legacy path: single-org client.
role, memberErr := h.orgStore.GetMembership(r.Context(), *oci.OrgID, uid)
if memberErr != nil || role == "" {
orgName := ""
if org, lookupErr := h.orgStore.GetOrgByID(r.Context(), *oci.OrgID); lookupErr == nil && org != nil {
orgName = org.DisplayName
}
_ = ui.OAuthAccessDeniedPage(clientName, orgName, returnURL).Render(r.Context(), w)
return "", nil
}
// Single-org: proceed without picker, encode the org.
singleOrgID = oci.OrgID
} else if h.grantStore != nil {
// Multi-org path: find all orgs this client is granted to that the user belongs to.
grants, grantErr := h.grantStore.ListUserEligibleOrgsForClient(r.Context(), clientID, uid)
if grantErr != nil {
slog.Error("authorize: failed to list eligible orgs", "client_id", clientID, "error", grantErr)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return "", nil
}
if len(grants) == 0 {
_ = ui.OAuthAccessDeniedNoGrant(clientName, returnURL).Render(r.Context(), w)
return "", nil
}
if len(grants) == 1 {
orgID := grants[0].OrgID
singleOrgID = &orgID
} else {
for _, g := range grants {
eligibleOrgs = append(eligibleOrgs, ui.OrgOption{
ID: g.OrgID,
Name: g.OrgName,
Slug: g.OrgSlug,
})
}
}
}
}
}
// If exactly one org matched (legacy or single-grant), no picker needed.
// Encode the org so the JWT generator uses it; no user interaction required.
if singleOrgID != nil && len(eligibleOrgs) == 0 {
// We need to show consent but auto-select the org. Pass it as a hidden field.
eligibleOrgs = nil // no picker shown; selectedOrgID will be set as hidden
}
// Parse requested scopes
var requestedScopes []string
if scope := r.FormValue("scope"); scope != "" {
requestedScopes = strings.Split(scope, " ")
} else {
requestedScopes = []string{"openid", "profile"}
}
// Extract domain from the registered client for the trust badge.
clientDomain := ""
if fetchedClient != nil {
if rawDomain := fetchedClient.GetDomain(); rawDomain != "" {
if parsed, err := url.Parse(rawDomain); err == nil {
clientDomain = parsed.Host
}
}
}
selectedOrgID := ""
if singleOrgID != nil {
selectedOrgID = *singleOrgID
}
csrfToken := nosurf.Token(r)
ui.ConsentPage(clientName, clientDomain, requestedScopes, csrfToken, "", "", "", eligibleOrgs, selectedOrgID).Render(r.Context(), w)
// Return empty userID to halt go-oauth2 — we rendered the page ourselves.
return "", nil
}
// Token handles the exchange of an Authorization Code (or Refresh Token) for an Access JWT.
// When the openid scope is granted and an IDTokenGenerator is configured, an id_token is
// injected into the response JSON.
func (h *OAuth2Handler) Token(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
// Capture the authorization code before HandleTokenRequest consumes the body.
// r.FormValue parses and caches the form; subsequent calls by go-oauth2 reuse the cache.
code := r.FormValue("code")
rc := &responseCapture{ResponseWriter: w}
err := h.server.HandleTokenRequest(rc, r)
if err != nil {
slog.Error("Token Request Error", "error", err)
}
if rc.statusCode == http.StatusOK && h.idTokenGen != nil {
if err := h.tryInjectIDToken(r.Context(), rc, code); err != nil {
slog.Error("id_token injection failed; returning server error", "error", err)
rc.statusCode = http.StatusInternalServerError
rc.body.Reset()
rc.body.Write([]byte(`{"error":"server_error","error_description":"internal server error"}`)) //nolint:errcheck
}
}
rc.flush()
}
// tryInjectIDToken parses the buffered token response and injects an id_token when the
// openid scope is present and the token has a user subject (not client_credentials).
// code is the authorization code from the request; used to retrieve a stored nonce.
// Returns an error if nonce retrieval fails (fail-closed: a Redis error on the nonce
// store aborts id_token generation rather than silently omitting the nonce).
func (h *OAuth2Handler) tryInjectIDToken(ctx context.Context, rc *responseCapture, code string) error {
var resp map[string]interface{}
if err := json.Unmarshal(rc.body.Bytes(), &resp); err != nil {
return nil
}
scope, _ := resp["scope"].(string)
scopeSet := make(map[string]bool)
for _, s := range strings.Fields(scope) {
scopeSet[s] = true
}
if !scopeSet["openid"] {
return nil
}
accessToken, _ := resp["access_token"].(string)
if accessToken == "" {
return nil
}
// ParseUnverified to extract sub/aud/exp from our own just-issued token without re-verifying.
parser := jwt.NewParser()
parsed, _, parseErr := parser.ParseUnverified(accessToken, jwt.MapClaims{})
if parseErr != nil {
return nil
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil
}
sub, _ := claims["sub"].(string)
aud, _ := claims["aud"].(string)
if sub == "" || sub == aud {
return nil // client_credentials token — no user to describe
}
var expiry time.Duration
if exp, ok := claims["exp"].(float64); ok {
if remaining := time.Until(time.Unix(int64(exp), 0)); remaining > 0 {
expiry = remaining
}
}
if expiry <= 0 {
expiry = time.Hour
}
var nonce string
if code != "" && h.nonceStore != nil {
n, err := h.nonceStore.ConsumeNonce(ctx, code)
if err != nil {
return fmt.Errorf("nonce retrieval failed: %w", err)
}
nonce = n
}
idToken, err := h.idTokenGen.GenerateIDToken(ctx, sub, aud, scope, accessToken, expiry, nonce)
if err != nil {
slog.Warn("id_token generation failed; omitting from response", "error", err)
return nil
}
resp["id_token"] = idToken
newBody, err := json.Marshal(resp)
if err != nil {
return nil
}
rc.body.Reset()
rc.body.Write(newBody) //nolint:errcheck
return nil
}
// Revoke handles invalidating a specific JWT by its JTI blocklist, or deleting a refresh token
func (h *OAuth2Handler) Revoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
tokenStr := r.FormValue("token")
if tokenStr == "" {
http.Error(w, "missing token parameter", http.StatusBadRequest)
return
}
tokenTypeHint := r.FormValue("token_type_hint")
// RFC 7009: The server responds with HTTP 200 OK regardless of whether the token
// was valid/found or not, to prevent leaking information. Only 500s or 400s on bad requests.
// Try JWT parsing first (Access Tokens) unless explicitly hinted heavily otherwise
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) {
return h.keyStore.PublicKey, nil
}, jwt.WithoutClaimsValidation(), jwt.WithValidMethods([]string{"RS256"}))
if err == nil && token.Valid {
if claims, ok := token.Claims.(jwt.MapClaims); ok {
if jti, ok := claims["jti"].(string); ok && jti != "" {
// Blocklist the JTI in Redis
_ = h.revocStore.RevokeJTI(r.Context(), jti, 10*time.Hour)
w.WriteHeader(http.StatusOK)
return
}
}
}
// If parsing as JWT failed, or it lacked a JTI, it's likely a Refresh Token (which our generator makes as UUIDs).
// Or maybe the token type hint specifically suggests it.
if tokenTypeHint == "refresh_token" || err != nil {
_ = h.server.Manager.RemoveRefreshToken(r.Context(), tokenStr)
} else {
// Just to be safe, try removing it as both if neither hint nor JWT structural match worked.
_ = h.server.Manager.RemoveAccessToken(r.Context(), tokenStr)
_ = h.server.Manager.RemoveRefreshToken(r.Context(), tokenStr)
}
w.WriteHeader(http.StatusOK)
}
package handlers
import (
"encoding/json"
"net/http"
"github.com/julienschmidt/httprouter"
)
// OIDCConfig represents the OpenID Connect discovery document
type OIDCConfig struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported"`
ResponseModesSupported []string `json:"response_modes_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
}
// OpenIDConfiguration serves the OIDC discovery document
func (h *DiscoveryHandler) OpenIDConfiguration(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
baseURL := h.appURL
config := OIDCConfig{
Issuer: baseURL,
AuthorizationEndpoint: baseURL + "/authorize",
TokenEndpoint: baseURL + "/token",
UserinfoEndpoint: baseURL + "/userinfo",
JwksURI: baseURL + "/.well-known/jwks.json",
RevocationEndpoint: baseURL + "/revoke",
ResponseTypesSupported: []string{"code"},
ResponseModesSupported: []string{"query"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
ScopesSupported: []string{"openid", "profile", "email", "offline_access"},
ClaimsSupported: []string{
"sub", "iss", "aud", "exp", "iat", "jti", "scope",
"name", "preferred_username", "updated_at", "email", "email_verified",
"org_id", "org_role", "at_hash",
},
GrantTypesSupported: []string{"authorization_code", "refresh_token", "client_credentials"},
TokenEndpointAuthMethods: []string{"client_secret_post", "none"},
CodeChallengeMethodsSupported: []string{"S256"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
goredis "github.com/go-redis/redis/v8"
"github.com/google/uuid"
"github.com/iabhishekrajput/anekdote-auth/internal/mailer"
// uuid is used for generating invite tokens (uuid.New().String())
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis/redisutil"
"github.com/iabhishekrajput/anekdote-auth/internal/types"
"github.com/iabhishekrajput/anekdote-auth/web/ui"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf"
)
const clientSecretFlashTTL = 60 * time.Second
var (
slugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$`)
claimKeyRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_:\-./]*$`)
reservedClaimKeys = func() map[string]struct{} {
keys := []string{
"sub", "iss", "aud", "exp", "iat", "jti", "nbf",
"scope", "org_id", "org_role", "name", "email",
"email_verified", "updated_at", "at_hash",
"auth_time", "nonce", "acr", "amr", "azp", "client_id",
"preferred_username",
}
m := make(map[string]struct{}, len(keys))
for _, k := range keys {
m[k] = struct{}{}
}
return m
}()
reservedSlugs = map[string]bool{
"accept": true, "admin": true, "api": true, "account": true,
"login": true, "register": true, "token": true, "revoke": true,
"authorize": true, "static": true, "clients": true, "members": true,
}
inviteKeyTTL = 24 * time.Hour
)
type invitePayload struct {
OrgID string `json:"org_id"`
InviterID string `json:"inviter_id"`
Role string `json:"role"`
Email string `json:"email"`
InviterEmail string `json:"inviter_email"`
OrgSlug string `json:"org_slug"`
OrgName string `json:"org_name"`
}
type OrgHandler struct {
orgStore *postgres.OrgStore
userStore *postgres.UserStore
clientStore *postgres.ClientStore
sessionStore *redis.SessionStore
revocStore *redis.RevocationStore
auditStore *postgres.AuditStore
mailer *mailer.Mailer
rdb *goredis.Client
encKey []byte // AES-256 key for client secret flash encryption
appURL string
}
func NewOrgHandler(
orgStore *postgres.OrgStore,
userStore *postgres.UserStore,
clientStore *postgres.ClientStore,
sessionStore *redis.SessionStore,
mailSvc *mailer.Mailer,
rdb *goredis.Client,
revocStore *redis.RevocationStore,
auditStore *postgres.AuditStore,
encKey []byte,
appURL string,
) *OrgHandler {
return &OrgHandler{
orgStore: orgStore,
userStore: userStore,
clientStore: clientStore,
sessionStore: sessionStore,
revocStore: revocStore,
auditStore: auditStore,
mailer: mailSvc,
rdb: rdb,
encKey: encKey,
appURL: appURL,
}
}
// ListOrgs handles GET /account/orgs
func (h *OrgHandler) ListOrgs(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
orgs, err := h.orgStore.ListOrgsForUserFull(r.Context(), userID)
if err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Failed to load organizations"), http.StatusFound)
return
}
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
csrfToken := nosurf.Token(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.OrgListPage(csrfToken, orgs, nil, isAdmin, r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// CreateOrg handles POST /account/orgs
func (h *OrgHandler) CreateOrg(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := r.FormValue("slug")
displayName := r.FormValue("display_name")
if !slugRegex.MatchString(slug) {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Invalid slug: must be 3-63 lowercase letters, numbers, or hyphens"), http.StatusFound)
return
}
if reservedSlugs[slug] {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Slug '"+slug+"' is reserved"), http.StatusFound)
return
}
if displayName == "" {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Display name is required"), http.StatusFound)
return
}
tx, err := h.orgStore.BeginTx(r.Context())
if err != nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Server error"), http.StatusFound)
return
}
defer tx.Rollback()
org, err := h.orgStore.CreateOrgWithOwner(r.Context(), tx, slug, displayName, userID)
if err != nil {
msg := "Failed to create organization"
if isUniqueViolation(err) {
msg = "Slug '" + slug + "' is already taken"
}
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape(msg), http.StatusFound)
return
}
if err := tx.Commit(); err != nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Server error"), http.StatusFound)
return
}
http.Redirect(w, r, "/account/orgs/"+org.Slug+"?message="+url.QueryEscape("Organization created"), http.StatusFound)
}
// AcceptInvite handles GET /account/orgs/accept?token=<T> (no RequireAuth)
func (h *OrgHandler) AcceptInvite(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
token := r.URL.Query().Get("token")
if token == "" {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid invite link"), http.StatusFound)
return
}
// Read invite from Redis
raw, err := h.rdb.Get(r.Context(), "org:invite:"+token).Result()
if err != nil {
if errors.Is(err, goredis.Nil) {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Invite link has expired or is invalid"), http.StatusFound)
return
}
http.Redirect(w, r, "/login?error="+url.QueryEscape("Server error"), http.StatusFound)
return
}
var inv invitePayload
if err := json.Unmarshal([]byte(raw), &inv); err != nil {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid invite"), http.StatusFound)
return
}
// Check if user is authenticated
sessionUserID, sessionErr := h.sessionStore.GetUserFromSession(r)
if sessionErr != nil || sessionUserID == "" {
// Not logged in — redirect to register with invite param preserved
http.Redirect(w, r, "/register?invite="+url.QueryEscape(token), http.StatusFound)
return
}
// Authenticated: verify the logged-in user's email matches the invite target
currentUser, err := h.userStore.GetByID(sessionUserID)
if err != nil || currentUser == nil {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Session error"), http.StatusFound)
return
}
if !strings.EqualFold(currentUser.Email, inv.Email) {
logoutRedirect := "/login?req=" + url.QueryEscape("/join?token="+token)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
invIsAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
_ = ui.InviteEmailMismatch(inv.Email, currentUser.Email, logoutRedirect, nosurf.Token(r), invIsAdmin).Render(r.Context(), w)
return
}
if inv.OrgID == "" {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Invalid invite"), http.StatusFound)
return
}
if err := h.orgStore.AddMember(r.Context(), inv.OrgID, sessionUserID, inv.Role, nil); err != nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Failed to join organization"), http.StatusFound)
return
}
// Cleanup Redis
h.rdb.Del(r.Context(), "org:invite:"+token)
h.rdb.SRem(r.Context(), "org:invites:"+inv.OrgID, token)
http.Redirect(w, r, "/account/orgs/"+inv.OrgSlug+"?message="+url.QueryEscape("You joined "+inv.OrgName), http.StatusFound)
}
// OrgDetail handles GET /account/orgs/:slug
func (h *OrgHandler) OrgDetail(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, err := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if err != nil || role == "" || role == "member" {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
members, err := h.orgStore.ListMembers(r.Context(), org.ID)
if err != nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Failed to load members"), http.StatusFound)
return
}
pending := h.loadPendingInvites(r.Context(), org.ID)
canEdit := role == "owner" || role == "admin"
isOwner := role == "owner"
var grantedClients []*postgres.OrgGrantItem
if isOwner {
grantedClients, _ = h.clientStore.ListOrgGrantedClients(r.Context(), org.ID)
}
// Outgoing pending grant requests this org has made (org B's view of its own requests).
outgoingRequests, _ := h.clientStore.ListGrantRequestsForOrg(r.Context(), org.ID)
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
csrfToken := nosurf.Token(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.OrgDetailPage(csrfToken, org, members, pending, userID, canEdit, isOwner, isAdmin,
grantedClients, outgoingRequests, r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// SendInvite handles POST /account/orgs/:slug/invites
func (h *OrgHandler) SendInvite(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
redirectBase := "/account/orgs/" + slug
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
inviteRole := r.FormValue("role")
if email == "" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Email is required"), http.StatusFound)
return
}
if inviteRole != "member" && inviteRole != "viewer" && inviteRole != "admin" {
inviteRole = "member"
}
targetUser, err := h.userStore.GetByEmail(email)
if err != nil && !errors.Is(err, postgres.ErrUserNotFound) {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Server error, please try again"), http.StatusFound)
return
}
if targetUser != nil {
memberRole, _ := h.orgStore.GetMembership(r.Context(), org.ID, targetUser.ID)
if memberRole != "" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("user is already a member of this organization"), http.StatusFound)
return
}
}
for _, p := range h.loadPendingInvites(r.Context(), org.ID) {
if p.Email == email {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("an invitation is already pending for this email"), http.StatusFound)
return
}
}
inviter, _ := h.userStore.GetByID(userID)
inviterEmail := "a member"
if inviter != nil {
inviterEmail = inviter.Email
}
token := uuid.New().String()
inv := invitePayload{
OrgID: org.ID,
InviterID: userID,
Role: inviteRole,
Email: email,
InviterEmail: inviterEmail,
OrgSlug: org.Slug,
OrgName: org.DisplayName,
}
data, _ := json.Marshal(inv)
pipe := h.rdb.Pipeline()
pipe.Set(r.Context(), "org:invite:"+token, string(data), inviteKeyTTL)
pipe.SAdd(r.Context(), "org:invites:"+org.ID, token)
if _, err := pipe.Exec(r.Context()); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to create invite"), http.StatusFound)
return
}
acceptURL := h.appURL + "/join?token=" + token
if h.mailer != nil {
if err := h.mailer.SendOrgInvite(r.Context(), email, org.DisplayName, inviterEmail, acceptURL); err != nil {
h.rdb.Del(r.Context(), "org:invite:"+token)
h.rdb.SRem(r.Context(), "org:invites:"+org.ID, token)
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to send invite email"), http.StatusFound)
return
}
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Invite sent to "+email), http.StatusFound)
}
// RevokeInvite handles POST /account/orgs/:slug/invites/:token/revoke
func (h *OrgHandler) RevokeInvite(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
token := ps.ByName("token")
redirectBase := "/account/orgs/" + slug
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
h.rdb.Del(r.Context(), "org:invite:"+token)
h.rdb.SRem(r.Context(), "org:invites:"+org.ID, token)
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Invite revoked"), http.StatusFound)
}
// ChangeMemberRole handles POST /account/orgs/:slug/members/:userID/role
func (h *OrgHandler) ChangeMemberRole(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
actorID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
targetIDStr := ps.ByName("userID")
redirectBase := "/account/orgs/" + slug
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
actorRole, _ := h.orgStore.GetMembership(r.Context(), org.ID, actorID)
if actorRole != "owner" && actorRole != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
targetID := targetIDStr
newRole := r.FormValue("role")
if err := h.orgStore.UpdateMemberRole(r.Context(), org.ID, targetID, newRole); err != nil {
if errors.Is(err, postgres.ErrInvalidRole) {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Cannot set role to 'owner' via this form"), http.StatusFound)
return
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to update role"), http.StatusFound)
return
}
msg := "Role updated"
if target, err := h.userStore.GetByID(targetID); err == nil {
msg = "Changed " + target.Email + " to " + newRole
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape(msg), http.StatusFound)
}
// RemoveMember handles POST /account/orgs/:slug/members/:userID/remove
func (h *OrgHandler) RemoveMember(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
actorID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
targetIDStr := ps.ByName("userID")
redirectBase := "/account/orgs/" + slug
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
actorRole, _ := h.orgStore.GetMembership(r.Context(), org.ID, actorID)
if actorRole != "owner" && actorRole != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
targetID := targetIDStr
if err := h.orgStore.RemoveMember(r.Context(), org.ID, targetID); err != nil {
if errors.Is(err, postgres.ErrOwnerCannotBeRemoved) {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Cannot remove the org owner"), http.StatusFound)
return
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to remove member"), http.StatusFound)
return
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Member removed"), http.StatusFound)
}
// storeSecretFlash encrypts secret with AES-256-GCM and stores it in Redis.
// The plaintext never appears in Redis — only ciphertext with a 60-second TTL.
func (h *OrgHandler) storeSecretFlash(ctx context.Context, clientID, secret string) error {
ct, err := redisutil.Encrypt(h.encKey, secret)
if err != nil {
return err
}
return h.rdb.Set(ctx, "oauth:client-secret-flash:"+clientID, ct, clientSecretFlashTTL).Err()
}
// popSecretFlash atomically reads, deletes, and decrypts the flash secret.
func (h *OrgHandler) popSecretFlash(ctx context.Context, clientID string) string {
ct, err := h.rdb.GetDel(ctx, "oauth:client-secret-flash:"+clientID).Result()
if err != nil {
return ""
}
plain, err := redisutil.Decrypt(h.encKey, ct)
if err != nil {
slog.Warn("org: failed to decrypt client secret flash", "client_id", clientID, "err", err)
return ""
}
return plain
}
// OrgClients handles GET /account/orgs/:slug/clients
func (h *OrgHandler) OrgClients(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, err := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if err != nil || role == "" || role == "member" {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
newClientID := r.URL.Query().Get("newClientID")
newSecret := ""
if newClientID != "" {
newSecret = h.popSecretFlash(r.Context(), newClientID)
}
clients, _ := h.clientStore.ListOrgClients(r.Context(), org.ID)
// For multi-org clients owned by this org, batch-load pending requests and connected orgs.
for _, c := range clients {
if c.IsGlobal && c.IsOwner {
c.PendingRequests, _ = h.clientStore.ListGrantRequestsForClient(r.Context(), c.ID)
c.ConnectedOrgs, _ = h.clientStore.ListOrgsGrantedClient(r.Context(), c.ID)
}
}
canEdit := role == "owner" || role == "admin"
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
csrfToken := nosurf.Token(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.OrgClientsPage(csrfToken, org, canEdit, isAdmin, clients, newClientID, newSecret,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// ExploreApps handles GET /account/orgs/:slug/explore
func (h *OrgHandler) ExploreApps(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, err := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if err != nil || role == "" || role == "member" {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
const pageSize = 20
cursor, _ := postgres.DecodeCursor(r.URL.Query().Get("cursor"))
clients, nextCursor, _, err := h.clientStore.ListDiscoverableClients(r.Context(), org.ID, pageSize, cursor)
if err != nil {
slog.Error("ExploreApps: failed to list clients", "error", err)
http.Redirect(w, r, "/account/orgs/"+org.Slug+"?error="+url.QueryEscape("Failed to load apps"), http.StatusFound)
return
}
canEdit := role == "owner" || role == "admin"
isOwner := role == "owner"
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
csrfToken := nosurf.Token(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.OrgExploreAppsPage(csrfToken, org, clients, nextCursor, canEdit, isOwner, isAdmin,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// RegisterClient handles POST /account/orgs/:slug/clients
func (h *OrgHandler) RegisterClient(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
name := r.FormValue("name")
redirectURI := r.FormValue("redirect_uri")
isPublic := r.FormValue("public") == "on"
isMultiOrg := r.FormValue("multi_org") == "on"
isServiceAccount := r.FormValue("service_account") == "on"
if isServiceAccount {
redirectURI = serviceAccountRedirectURI
isPublic = false
isMultiOrg = false
}
if len(name) == 0 || len(name) > 255 {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client name must be 1–255 characters"), http.StatusFound)
return
}
if err := validateRedirectURI(redirectURI); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
clientID, plainSecret, err := h.clientStore.CreateOrgClient(r.Context(), org.ID, name, redirectURI, isPublic, isMultiOrg)
if err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to register client"), http.StatusFound)
return
}
if plainSecret != "" {
if err := h.storeSecretFlash(r.Context(), clientID, plainSecret); err != nil {
http.Redirect(w, r, redirectBase+"?newClientID="+url.QueryEscape(clientID)+"&error="+url.QueryEscape("Client created but secret could not be saved. Click Rotate Secret to reveal a new one."), http.StatusFound)
return
}
}
http.Redirect(w, r, redirectBase+"?newClientID="+url.QueryEscape(clientID), http.StatusFound)
}
// DeleteClient handles POST /account/orgs/:slug/clients/:clientID/delete
func (h *OrgHandler) DeleteClient(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
if err := h.clientStore.DeleteOrgClient(r.Context(), clientID, org.ID); err != nil {
if errors.Is(err, postgres.ErrGlobalClientUsesGrant) {
// Multi-org client: remove this org's grant instead of deleting the client row.
if rErr := h.clientStore.RevokeOrgAccess(r.Context(), clientID, org.ID); rErr != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to remove client"), http.StatusFound)
return
}
// Best-effort token revocation for this org's grants.
if h.rdb != nil && h.revocStore != nil {
indexKey := "oauth:user-org-tokens:*:" + org.ID
if keys, kErr := h.rdb.Keys(r.Context(), indexKey).Result(); kErr == nil {
for _, key := range keys {
if jtis, err := h.rdb.SMembers(r.Context(), key).Result(); err == nil {
for _, jti := range jtis {
h.revocStore.RevokeJTI(r.Context(), jti, time.Hour)
}
}
}
}
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Removed from this org"), http.StatusFound)
return
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client not found"), http.StatusFound)
return
}
// Best-effort: revoke all outstanding tokens issued to this client.
// Errors are non-fatal — the client row is already deleted and tokens
// will expire naturally within their TTL (~1 hour).
if h.revocStore != nil {
indexKey := "oauth:client-tokens:" + clientID
if jtis, err := h.rdb.SMembers(r.Context(), indexKey).Result(); err == nil && len(jtis) > 0 {
for _, jti := range jtis {
h.revocStore.RevokeJTI(r.Context(), jti, time.Hour)
}
h.rdb.Del(r.Context(), indexKey)
}
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Client deleted"), http.StatusFound)
}
// RotateClientSecret handles POST /account/orgs/:slug/clients/:clientID/rotate-secret
func (h *OrgHandler) RotateClientSecret(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
newSecret, err := h.clientStore.RotateOrgClientSecret(r.Context(), clientID, org.ID)
if err != nil {
msg := "Failed to rotate secret"
if errors.Is(err, postgres.ErrClientNotFound) {
msg = "Client not found"
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(msg), http.StatusFound)
return
}
if err := h.storeSecretFlash(r.Context(), clientID, newSecret); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Secret rotated but could not be displayed. Try rotating again."), http.StatusFound)
return
}
// Notify owner org's admins that the secret was rotated.
if h.mailer != nil {
orgDisplayName := org.DisplayName
go func() {
emails, err := h.userStore.ListOrgAdmins(context.Background(), org.ID)
if err != nil || len(emails) == 0 {
return
}
clientName, _ := h.clientStore.GetClientName(context.Background(), clientID)
if clientName == "" {
clientName = clientID
}
if err := h.mailer.SendSecretRotated(context.Background(), emails, clientName, orgDisplayName); err != nil {
slog.Error("secret rotated email failed", "client", clientID, "err", err)
}
}()
}
http.Redirect(w, r, redirectBase+"?newClientID="+url.QueryEscape(clientID), http.StatusFound)
}
// TransferOwnershipAndLeave handles POST /account/orgs/:slug/transfer-ownership
func (h *OrgHandler) TransferOwnershipAndLeave(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
redirectBase := "/account/orgs/" + slug
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, err := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if err != nil || role != "owner" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
newOwnerID := strings.TrimSpace(r.FormValue("new_owner_id"))
if newOwnerID == "" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Invalid user ID"), http.StatusFound)
return
}
if newOwnerID == userID {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Cannot transfer to yourself"), http.StatusFound)
return
}
if err := h.orgStore.TransferOwnershipAndLeave(r.Context(), org.ID, userID, newOwnerID); err != nil {
if errors.Is(err, postgres.ErrTransferTargetNotMember) {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("User is not a member of this org"), http.StatusFound)
return
}
slog.Error("transfer ownership failed", "org", slug, "err", err)
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to transfer ownership"), http.StatusFound)
return
}
// Best-effort: revoke org-scoped tokens for the departing owner.
if h.revocStore != nil && h.rdb != nil {
indexKey := "oauth:user-org-tokens:" + userID + ":" + org.ID
if jtis, err := h.rdb.SMembers(r.Context(), indexKey).Result(); err == nil && len(jtis) > 0 {
for _, jti := range jtis {
h.revocStore.RevokeJTI(r.Context(), jti, time.Hour)
}
h.rdb.Del(r.Context(), indexKey)
}
}
// Audit log.
if h.auditStore != nil {
_ = h.auditStore.Log(r.Context(), userID, postgres.AuditActionTransferOrgOwnership,
"org", org.ID, r.RemoteAddr, r.UserAgent())
}
// Best-effort: notify new owner by email in a goroutine so email failure
// does not block or reverse the already-committed transfer.
if h.mailer != nil {
newOwner, err := h.userStore.GetByID(newOwnerID)
if err == nil && newOwner != nil {
go func() {
if err := h.mailer.SendOwnershipTransfer(context.Background(), newOwner.Email, org.DisplayName, org.Slug, h.appURL); err != nil {
slog.Error("failed to send ownership transfer email", "to", newOwner.Email, "org", slug, "err", err)
}
}()
}
}
http.Redirect(w, r, "/account?message="+url.QueryEscape("Ownership of "+org.DisplayName+" transferred. You've been removed from the org."), http.StatusFound)
}
// LeaveOrg handles POST /account/orgs/:slug/leave
func (h *OrgHandler) LeaveOrg(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, err := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if err != nil {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Failed to verify membership"), http.StatusFound)
return
}
if role == "" {
http.Redirect(w, r, "/account?error="+url.QueryEscape("You are not a member of this organization"), http.StatusFound)
return
}
if err := h.orgStore.RemoveMember(r.Context(), org.ID, userID); err != nil {
if errors.Is(err, postgres.ErrOwnerCannotBeRemoved) {
http.Redirect(w, r, "/account?error="+url.QueryEscape("Transfer ownership before leaving the organization"), http.StatusFound)
return
}
http.Redirect(w, r, "/account?error="+url.QueryEscape("Failed to leave organization"), http.StatusFound)
return
}
// Best-effort: revoke all org-scoped tokens issued to this user for this org.
if h.revocStore != nil && h.rdb != nil {
indexKey := "oauth:user-org-tokens:" + userID + ":" + org.ID
if jtis, err := h.rdb.SMembers(r.Context(), indexKey).Result(); err == nil && len(jtis) > 0 {
for _, jti := range jtis {
h.revocStore.RevokeJTI(r.Context(), jti, time.Hour)
}
h.rdb.Del(r.Context(), indexKey)
}
}
http.Redirect(w, r, "/account?message="+url.QueryEscape("You've left "+org.DisplayName+"."), http.StatusFound)
}
// DeleteOrg handles POST /account/orgs/:slug/delete — owner-only org deletion.
func (h *OrgHandler) DeleteOrg(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, err := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if err != nil || role != "owner" {
http.Redirect(w, r, "/account/orgs/"+slug+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
// Require the slug to be typed as confirmation.
if r.FormValue("confirm_slug") != slug {
http.Redirect(w, r, "/account/orgs/"+slug+"?error="+url.QueryEscape("Confirmation slug did not match"), http.StatusFound)
return
}
orgID := org.ID
orgName := org.DisplayName
if err := h.orgStore.DeleteOrg(r.Context(), orgID); err != nil {
http.Redirect(w, r, "/account/orgs/"+slug+"?error="+url.QueryEscape("Failed to delete organization"), http.StatusFound)
return
}
// Clean up pending invites from Redis.
h.cleanupOrgInvites(r.Context(), orgID)
if h.auditStore != nil {
go func() {
_ = h.auditStore.Log(context.Background(), userID, postgres.AuditActionDeleteOrg,
"org", orgID, "", "owner")
}()
}
_ = orgName
http.Redirect(w, r, "/account/orgs?message="+url.QueryEscape("Organization deleted"), http.StatusFound)
}
// cleanupOrgInvites removes all pending Redis invite keys for an org.
func (h *OrgHandler) cleanupOrgInvites(ctx context.Context, orgID string) {
tokens, err := h.rdb.SMembers(ctx, "org:invites:"+orgID).Result()
if err != nil {
return
}
for _, token := range tokens {
h.rdb.Del(ctx, "org:invite:"+token)
}
h.rdb.Del(ctx, "org:invites:"+orgID)
}
// loadPendingInvites reads org:invites:{orgID} SET and fetches each invite payload,
// lazily pruning stale tokens.
func (h *OrgHandler) loadPendingInvites(ctx context.Context, orgID string) []ui.OrgPendingMember {
tokens, err := h.rdb.SMembers(ctx, "org:invites:"+orgID).Result()
if err != nil {
return nil
}
var pending []ui.OrgPendingMember
for _, token := range tokens {
raw, err := h.rdb.Get(ctx, "org:invite:"+token).Result()
if err != nil {
h.rdb.SRem(ctx, "org:invites:"+orgID, token)
continue
}
var inv invitePayload
if err := json.Unmarshal([]byte(raw), &inv); err != nil {
h.rdb.SRem(ctx, "org:invites:"+orgID, token)
continue
}
pending = append(pending, ui.OrgPendingMember{
Email: inv.Email,
Role: inv.Role,
Token: token,
})
}
sort.Slice(pending, func(i, j int) bool { return pending[i].Email < pending[j].Email })
return pending
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "duplicate key") || strings.Contains(msg, "unique constraint")
}
// GrantClientAccess handles POST /account/orgs/:slug/grants.
// For single-org clients it grants access directly (legacy path).
// For multi-org clients it creates a pending access request and emails the owner org.
func (h *OrgHandler) GrantClientAccess(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
redirectBase := "/account/orgs/" + slug
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs", http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
clientID := strings.TrimSpace(r.FormValue("client_id"))
if clientID == "" {
http.Redirect(w, r, redirectBase+"?error=Client+ID+is+required", http.StatusFound)
return
}
isGlobal, err := h.clientStore.IsGlobalClient(r.Context(), clientID)
if errors.Is(err, postgres.ErrClientNotFound) {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client not found"), http.StatusFound)
return
}
if err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Server error"), http.StatusFound)
return
}
if !isGlobal {
// Single-org client: direct grant (legacy path).
if err := h.clientStore.GrantOrgAccess(r.Context(), clientID, org.ID, userID); err != nil {
http.Redirect(w, r, redirectBase+"?error=Failed+to+grant+access", http.StatusFound)
return
}
if h.auditStore != nil {
_ = h.auditStore.Log(context.Background(), userID, postgres.AuditActionGrantOrgClient,
"client", clientID, r.RemoteAddr, r.UserAgent())
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Client access granted"), http.StatusFound)
return
}
// Multi-org client: create a pending access request.
ownerOrgID, err := h.clientStore.GetClientOwnerOrgID(r.Context(), clientID)
if err != nil || ownerOrgID == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client has no owner org"), http.StatusFound)
return
}
gr, err := h.clientStore.CreateGrantRequest(r.Context(), clientID, org.ID, *ownerOrgID, userID)
if err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to create access request"), http.StatusFound)
return
}
if gr == nil {
// ON CONFLICT DO NOTHING fired — a pending request already exists.
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("A pending request already exists for this client"), http.StatusFound)
return
}
if h.auditStore != nil {
_ = h.auditStore.Log(r.Context(), userID, postgres.AuditActionGrantOrgClient,
"client", clientID, r.RemoteAddr, r.UserAgent())
}
// Send email to owner org's admins/owners.
if h.mailer != nil {
ownerOrg, oErr := h.orgStore.GetOrgByID(r.Context(), *ownerOrgID)
if oErr == nil && ownerOrg != nil {
members, mErr := h.orgStore.ListMembers(r.Context(), *ownerOrgID)
if mErr == nil {
var adminEmails []string
for _, m := range members {
if m.Role == "owner" || m.Role == "admin" {
adminEmails = append(adminEmails, m.UserEmail)
}
}
if len(adminEmails) > 0 {
requestsURL := h.appURL + "/account/orgs/" + ownerOrg.Slug + "/clients/" + clientID + "/requests"
go func() {
if eErr := h.mailer.SendClientGrantRequest(context.Background(), adminEmails, gr.ClientName, org.DisplayName, requestsURL); eErr != nil {
slog.Error("grant request email failed", "client", clientID, "err", eErr)
}
}()
}
}
}
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Access request sent. The client owner will review it."), http.StatusFound)
}
// RevokeClientAccess handles POST /account/orgs/:slug/grants/:clientID/revoke — owner removes a client grant.
func (h *OrgHandler) RevokeClientAccess(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs", http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := h.clientStore.RevokeOrgAccess(r.Context(), clientID, org.ID); err != nil {
http.Redirect(w, r, "/account/orgs/"+slug+"?error=Failed+to+revoke+access", http.StatusFound)
return
}
// Blocklist all outstanding JTIs for users of this org issued for this client's grants.
if h.rdb != nil && h.revocStore != nil {
pattern := "oauth:user-org-tokens:*:" + org.ID
var cursor uint64
for {
var batch []string
var scanErr error
batch, cursor, scanErr = h.rdb.Scan(r.Context(), cursor, pattern, 100).Result()
if scanErr != nil {
break
}
for _, key := range batch {
jtis, err := h.rdb.SMembers(r.Context(), key).Result()
if err != nil {
continue
}
for _, jti := range jtis {
_ = h.revocStore.RevokeJTI(r.Context(), jti, 2*time.Hour)
}
}
if cursor == 0 {
break
}
}
}
if h.auditStore != nil {
_ = h.auditStore.Log(context.Background(), userID, postgres.AuditActionRevokeOrgClient,
"client", clientID, r.RemoteAddr, r.UserAgent())
}
// Notify client owner org's admins that this org removed its own grant.
if h.mailer != nil {
orgDisplayName := org.DisplayName
go func() {
ownerOrgID, err := h.clientStore.GetClientOwnerOrgID(context.Background(), clientID)
if err != nil || ownerOrgID == nil {
return
}
emails, err := h.userStore.ListOrgAdmins(context.Background(), *ownerOrgID)
if err != nil || len(emails) == 0 {
return
}
clientName, _ := h.clientStore.GetClientName(context.Background(), clientID)
if clientName == "" {
clientName = clientID
}
if err := h.mailer.SendGrantRevoked(context.Background(), emails, clientName, orgDisplayName, false, ""); err != nil {
slog.Error("grant revoked email failed", "client", clientID, "err", err)
}
}()
}
http.Redirect(w, r, "/account/orgs/"+slug+"?message="+url.QueryEscape("Client access revoked"), http.StatusFound)
}
// ApproveGrantRequest handles POST /account/orgs/:slug/clients/:clientID/requests/:requestID/approve
func (h *OrgHandler) ApproveGrantRequest(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
requestIDStr := ps.ByName("requestID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Verify the client is owned by this org (prevents cross-org privilege escalation).
ownerOrgID, err := h.clientStore.GetClientOwnerOrgID(r.Context(), clientID)
if err != nil || ownerOrgID == nil || *ownerOrgID != org.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
requestID := requestIDStr
gr, err := h.clientStore.ApproveGrantRequest(r.Context(), requestID, clientID, org.ID, userID)
if err != nil {
if errors.Is(err, postgres.ErrGrantRequestNotPending) {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Request already resolved"), http.StatusFound)
return
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to approve request"), http.StatusFound)
return
}
if h.auditStore != nil {
_ = h.auditStore.Log(context.Background(), userID, postgres.AuditActionGrantOrgClient,
"client", clientID, r.RemoteAddr, r.UserAgent())
}
// Notify requester org's admins that their request was approved.
if h.mailer != nil {
requesterOrgID := gr.RequesterOrgID
go func() {
emails, err := h.userStore.ListOrgAdmins(context.Background(), requesterOrgID)
if err != nil || len(emails) == 0 {
return
}
clientName, _ := h.clientStore.GetClientName(context.Background(), clientID)
if clientName == "" {
clientName = clientID
}
clientsURL := h.appURL + "/account/orgs"
if err := h.mailer.SendGrantApproved(context.Background(), emails, clientName, org.DisplayName, clientsURL); err != nil {
slog.Error("grant approved email failed", "client", clientID, "err", err)
}
}()
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Access request approved"), http.StatusFound)
}
// DenyGrantRequest handles POST /account/orgs/:slug/clients/:clientID/requests/:requestID/deny
func (h *OrgHandler) DenyGrantRequest(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
requestIDStr := ps.ByName("requestID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Verify the client is owned by this org.
ownerOrgID, err := h.clientStore.GetClientOwnerOrgID(r.Context(), clientID)
if err != nil || ownerOrgID == nil || *ownerOrgID != org.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
requestID := requestIDStr
gr, err := h.clientStore.DenyGrantRequest(r.Context(), requestID, clientID, org.ID, userID)
if err != nil {
if errors.Is(err, postgres.ErrGrantRequestNotPending) {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Request already resolved"), http.StatusFound)
return
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to deny request"), http.StatusFound)
return
}
// Notify requester org's admins that their request was denied.
if h.mailer != nil {
requesterOrgID := gr.RequesterOrgID
go func() {
emails, err := h.userStore.ListOrgAdmins(context.Background(), requesterOrgID)
if err != nil || len(emails) == 0 {
return
}
clientName, _ := h.clientStore.GetClientName(context.Background(), clientID)
if clientName == "" {
clientName = clientID
}
if err := h.mailer.SendGrantDenied(context.Background(), emails, clientName, org.DisplayName); err != nil {
slog.Error("grant denied email failed", "client", clientID, "err", err)
}
}()
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Access request denied"), http.StatusFound)
}
// EditClient handles GET /account/orgs/:slug/clients/:clientID/edit
func (h *OrgHandler) EditClient(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
clients, _ := h.clientStore.ListOrgClients(r.Context(), org.ID)
var client *postgres.OrgClient
for _, c := range clients {
if c.ID == clientID && c.IsOwner {
client = c
break
}
}
if client == nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client not found"), http.StatusFound)
return
}
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.OrgClientEditPage(nosurf.Token(r), org, client, true, isAdmin,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// EditClientPost handles POST /account/orgs/:slug/clients/:clientID/edit
func (h *OrgHandler) EditClientPost(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
redirectBase := "/account/orgs/" + slug + "/clients/" + clientID + "/edit"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
name := strings.TrimSpace(r.FormValue("name"))
redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
if len(name) == 0 || len(name) > 255 {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client name must be 1–255 characters"), http.StatusFound)
return
}
if err := validateRedirectURI(redirectURI); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
if err := h.clientStore.UpdateOrgClient(r.Context(), clientID, org.ID, name, redirectURI); err != nil {
msg := "Failed to update client"
if errors.Is(err, postgres.ErrClientNotFound) {
msg = "Client not found"
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(msg), http.StatusFound)
return
}
http.Redirect(w, r, "/account/orgs/"+slug+"/clients?message="+url.QueryEscape("Client updated"), http.StatusFound)
}
// ClientRequestHistory handles GET /account/orgs/:slug/clients/:clientID/requests
func (h *OrgHandler) ClientRequestHistory(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
ownerOrgID, err := h.clientStore.GetClientOwnerOrgID(r.Context(), clientID)
if err != nil || ownerOrgID == nil || *ownerOrgID != org.ID {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client not found"), http.StatusFound)
return
}
requests, _ := h.clientStore.ListAllGrantRequestsForClient(r.Context(), clientID)
clients, _ := h.clientStore.ListOrgClients(r.Context(), org.ID)
var client *postgres.OrgClient
for _, c := range clients {
if c.ID == clientID {
client = c
break
}
}
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.OrgClientRequestHistoryPage(nosurf.Token(r), org, client, requests, isAdmin,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// SetGrantScopes handles POST /account/orgs/:slug/clients/:clientID/grants/:orgID/scopes
// The owner of a multi-org client can restrict which scopes are issued to users from a granted org.
func (h *OrgHandler) SetGrantScopes(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
grantedOrgIDStr := ps.ByName("orgID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Verify the client is owned by this org.
ownerOrgID, err := h.clientStore.GetClientOwnerOrgID(r.Context(), clientID)
if err != nil || ownerOrgID == nil || *ownerOrgID != org.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
grantedOrgID := grantedOrgIDStr
// Normalise: trim spaces, collapse multiple spaces, deduplicate.
rawScopes := strings.TrimSpace(r.FormValue("allowed_scopes"))
seen := make(map[string]bool)
var normalised []string
for _, s := range strings.Fields(rawScopes) {
if !seen[s] {
seen[s] = true
normalised = append(normalised, s)
}
}
scopes := strings.Join(normalised, " ")
if err := h.clientStore.SetGrantScopes(r.Context(), clientID, grantedOrgID, scopes); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to update scopes"), http.StatusFound)
return
}
if h.auditStore != nil {
_ = h.auditStore.Log(r.Context(), userID, postgres.AuditActionGrantOrgClient,
"client", clientID, r.RemoteAddr, r.UserAgent())
}
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Scope restriction updated"), http.StatusFound)
}
// DeveloperApps handles GET /account/apps — cross-org client management portal.
func (h *OrgHandler) DeveloperApps(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
clients, err := h.clientStore.ListClientsForUser(r.Context(), userID)
if err != nil {
slog.Error("DeveloperApps: failed to list clients", "err", err)
http.Redirect(w, r, "/account?error="+url.QueryEscape("Failed to load apps"), http.StatusFound)
return
}
// Load orgs where user is owner/admin for the "Register New App" form picker.
orgs, _ := h.orgStore.ListOrgsForUserFull(r.Context(), userID)
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
csrfToken := nosurf.Token(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.DeveloperAppsPage(csrfToken, clients, orgs, isAdmin,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// RegisterDevApp handles POST /account/apps — quick client registration from the developer portal.
func (h *OrgHandler) RegisterDevApp(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
redirectBase := "/account/apps"
orgID := strings.TrimSpace(r.FormValue("org_id"))
if orgID == "" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Invalid org selection"), http.StatusFound)
return
}
// Verify the user is owner/admin of the chosen org.
role, _ := h.orgStore.GetMembership(r.Context(), orgID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied for that org"), http.StatusFound)
return
}
name := strings.TrimSpace(r.FormValue("name"))
redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
isPublic := r.FormValue("public") == "on"
isMultiOrg := r.FormValue("multi_org") == "on"
isServiceAccount := r.FormValue("service_account") == "on"
if isServiceAccount {
redirectURI = serviceAccountRedirectURI
isPublic = false
isMultiOrg = false
}
if len(name) == 0 || len(name) > 255 {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client name must be 1–255 characters"), http.StatusFound)
return
}
if err := validateRedirectURI(redirectURI); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
clientID, plainSecret, err := h.clientStore.CreateOrgClient(r.Context(), orgID, name, redirectURI, isPublic, isMultiOrg)
if err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to register app"), http.StatusFound)
return
}
// Look up the org slug to redirect to the org's client list where the secret flash can be shown.
org, err := h.orgStore.GetOrgByID(r.Context(), orgID)
if err != nil || org == nil {
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("App registered"), http.StatusFound)
return
}
if plainSecret != "" {
if err := h.storeSecretFlash(r.Context(), clientID, plainSecret); err != nil {
http.Redirect(w, r, "/account/orgs/"+org.Slug+"/clients?newClientID="+url.QueryEscape(clientID)+"&error="+url.QueryEscape("App registered but secret could not be saved. Click Rotate Secret to reveal it."), http.StatusFound)
return
}
}
http.Redirect(w, r, "/account/orgs/"+org.Slug+"/clients?newClientID="+url.QueryEscape(clientID), http.StatusFound)
}
func validateRedirectURI(rawURI string) error {
if rawURI == serviceAccountRedirectURI {
return nil
}
if rawURI == "" {
return errors.New("redirect URI is required")
}
u, err := url.Parse(rawURI)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return errors.New("redirect URI must start with http:// or https://")
}
if strings.Contains(rawURI, "*") {
return errors.New("redirect URI must not contain wildcards")
}
return nil
}
const serviceAccountRedirectURI = "urn:anekdote:service-account"
var validDestinations = map[string]bool{
"access_token": true,
"id_token": true,
"token": true,
"userinfo": true,
"access_token,id_token": true,
"access_token,userinfo": true,
"id_token,userinfo": true,
"token,userinfo": true,
"access_token,id_token,userinfo": true,
}
// validateClaims parses and validates key[]/type[]/value[]/destination[] form arrays.
// destinations may be nil when the form does not include a destinations column (defaults to "token").
// Called by both ClientClaimsPost and AdminClientClaimsPost.
func validateClaims(keys, types, values, destinations []string) ([]postgres.ClaimDefinition, error) {
if len(keys) != len(types) || len(keys) != len(values) {
return nil, errors.New("malformed submission: key, type, and value arrays must have equal length")
}
if destinations != nil && len(keys) != len(destinations) {
return nil, errors.New("malformed submission: destinations array length mismatch")
}
if len(keys) > 20 {
return nil, errors.New("maximum 20 claims per client")
}
var defs []postgres.ClaimDefinition
approxSize := 2 // outer braces
for i, k := range keys {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if len(k) > 100 {
return nil, errors.New("claim key must be 100 characters or fewer")
}
if !claimKeyRegex.MatchString(k) {
return nil, errors.New("claim key \"" + k + "\" contains invalid characters")
}
if _, reserved := reservedClaimKeys[strings.ToLower(k)]; reserved {
return nil, errors.New("\"" + k + "\" is a reserved claim name and cannot be overridden")
}
rawVal := strings.TrimSpace(values[i])
var valueType, rawValue string
switch types[i] {
case "string":
valueType, rawValue = "string", rawVal
approxSize += len(k) + len(rawVal) + 6
case "number":
lower := strings.ToLower(rawVal)
if lower == "nan" || lower == "inf" || lower == "+inf" || lower == "-inf" || lower == "infinity" || lower == "-infinity" {
return nil, errors.New("claim \"" + k + "\": number value must be finite")
}
var f float64
if _, err := fmt.Sscanf(rawVal, "%g", &f); err != nil {
return nil, errors.New("claim \"" + k + "\": invalid number value")
}
valueType, rawValue = "number", fmt.Sprintf("%g", f)
approxSize += len(k) + len(rawValue) + 4
case "boolean":
valueType = "boolean"
if rawVal == "true" {
rawValue = "true"
} else {
rawValue = "false"
}
approxSize += len(k) + 5 + 4
default:
return nil, errors.New("claim \"" + k + "\": unknown type \"" + types[i] + "\"")
}
dest := "token"
if destinations != nil && i < len(destinations) {
dest = strings.TrimSpace(destinations[i])
}
if !validDestinations[dest] {
return nil, errors.New("claim \"" + k + "\": invalid destination \"" + dest + "\"")
}
defs = append(defs, postgres.ClaimDefinition{
Key: k,
ValueType: valueType,
Value: rawValue,
Destinations: dest,
})
}
if approxSize > 4096 {
return nil, errors.New("total custom claims JSON must be 4 KB or less")
}
return defs, nil
}
// ClientClaimsPage handles GET /account/orgs/:slug/clients/:clientID/claims
func (h *OrgHandler) ClientClaimsPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
redirectBase := "/account/orgs/" + slug + "/clients"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
ownerOrgID, err := h.clientStore.GetClientOwnerOrgID(r.Context(), clientID)
if err != nil || ownerOrgID == nil || *ownerOrgID != org.ID {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Client not found"), http.StatusFound)
return
}
clientName, _ := h.clientStore.GetClientName(r.Context(), clientID)
existing, _ := h.clientStore.ListCustomClaims(r.Context(), clientID)
isAdmin, _ := r.Context().Value(types.IsAdminContextKey).(bool)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = ui.ClientClaimsPage(nosurf.Token(r), org, clientID, clientName, existing, isAdmin,
r.URL.Query().Get("error"), r.URL.Query().Get("message")).Render(r.Context(), w)
}
// ClientClaimsPost handles POST /account/orgs/:slug/clients/:clientID/claims
func (h *OrgHandler) ClientClaimsPost(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID := r.Context().Value(types.UserContextKey).(string)
slug := ps.ByName("slug")
clientID := ps.ByName("clientID")
redirectBase := "/account/orgs/" + slug + "/clients/" + clientID + "/claims"
org, err := h.orgStore.GetOrgBySlug(r.Context(), slug)
if err != nil || org == nil {
http.Redirect(w, r, "/account/orgs?error="+url.QueryEscape("Organization not found"), http.StatusFound)
return
}
role, _ := h.orgStore.GetMembership(r.Context(), org.ID, userID)
if role != "owner" && role != "admin" {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Access denied"), http.StatusFound)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Invalid form data"), http.StatusFound)
return
}
keys := r.Form["key[]"]
claimTypes := r.Form["type[]"]
values := r.Form["value[]"]
destinations := r.Form["destination[]"]
defs, err := validateClaims(keys, claimTypes, values, destinations)
if err != nil {
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
if err := h.clientStore.SetCustomClaims(r.Context(), clientID, org.ID, defs); err != nil {
msg := "Failed to save claims"
if errors.Is(err, postgres.ErrClientNotFound) {
msg = "Client not found"
}
http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape(msg), http.StatusFound)
return
}
_ = h.auditStore.Log(r.Context(), userID, postgres.AuditActionSetCustomClaims, "client", clientID, r.RemoteAddr, r.UserAgent())
http.Redirect(w, r, redirectBase+"?message="+url.QueryEscape("Claims saved"), http.StatusFound)
}
package handlers
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/go-redis/redis/v8"
"github.com/julienschmidt/httprouter"
)
const probeTimeout = 2 * time.Second
type ProbeHandler struct {
db *sql.DB
redisClient *redis.Client
}
type probeResponse struct {
Status string `json:"status"`
Checks map[string]string `json:"checks,omitempty"`
}
func NewProbeHandler(db *sql.DB, redisClient *redis.Client) *ProbeHandler {
return &ProbeHandler{
db: db,
redisClient: redisClient,
}
}
func (h *ProbeHandler) Health(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
writeProbeResponse(w, http.StatusOK, probeResponse{Status: "ok"})
}
func (h *ProbeHandler) Ready(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
checks := map[string]string{
"postgres": "ok",
"redis": "ok",
}
statusCode := http.StatusOK
status := "ready"
if h.db == nil || h.db.PingContext(ctx) != nil {
checks["postgres"] = "down"
statusCode = http.StatusServiceUnavailable
status = "not_ready"
}
if h.redisClient == nil || h.redisClient.Ping(ctx).Err() != nil {
checks["redis"] = "down"
statusCode = http.StatusServiceUnavailable
status = "not_ready"
}
writeProbeResponse(w, statusCode, probeResponse{
Status: status,
Checks: checks,
})
}
func writeProbeResponse(w http.ResponseWriter, statusCode int, payload probeResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(payload)
}
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
goredis "github.com/go-redis/redis/v8"
"github.com/golang-jwt/jwt/v5"
"github.com/iabhishekrajput/anekdote-auth/internal/crypto"
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/julienschmidt/httprouter"
)
// UserInfoUserStore is the minimal interface UserInfoHandler needs for user lookup.
type UserInfoUserStore interface {
GetByID(id string) (*models.User, error)
}
// UserInfoRevStore is the minimal interface UserInfoHandler needs for revocation checks.
type UserInfoRevStore interface {
IsRevoked(ctx context.Context, jti string) (bool, error)
}
// UserInfoTombstoneStore checks for a deleted-user tombstone in Redis.
type UserInfoTombstoneStore interface {
Exists(ctx context.Context, keys ...string) *goredis.IntCmd
}
// UserInfoCustomClaimsReader reads per-client custom claims for /userinfo injection.
type UserInfoCustomClaimsReader interface {
GetCustomClaims(ctx context.Context, clientID, grantedScope, destination string) (map[string]any, error)
GetCustomClaimsForContext(ctx context.Context, clientID, grantedScope, destination string, claimCtx postgres.CustomClaimContext) (map[string]any, error)
}
// reservedUserInfoClaims is the set of claim names that custom claims cannot override in /userinfo.
var reservedUserInfoClaims = map[string]struct{}{
"sub": {}, "iss": {}, "aud": {}, "exp": {}, "iat": {}, "jti": {}, "nbf": {},
"scope": {}, "org_id": {}, "org_role": {}, "name": {}, "email": {},
"email_verified": {}, "updated_at": {}, "at_hash": {},
"auth_time": {}, "nonce": {}, "acr": {}, "amr": {}, "azp": {}, "client_id": {},
"preferred_username": {},
}
type UserInfoHandler struct {
keyStore *crypto.KeyStore
userStore UserInfoUserStore
revStore UserInfoRevStore
tombstoneDB UserInfoTombstoneStore
claimsReader UserInfoCustomClaimsReader
}
func NewUserInfoHandler(userStore UserInfoUserStore, keyStore *crypto.KeyStore, revStore UserInfoRevStore, rdb UserInfoTombstoneStore) *UserInfoHandler {
return &UserInfoHandler{
keyStore: keyStore,
userStore: userStore,
revStore: revStore,
tombstoneDB: rdb,
}
}
// WithCustomClaimsReader wires in a claims reader for /userinfo custom claim injection.
func (h *UserInfoHandler) WithCustomClaimsReader(r UserInfoCustomClaimsReader) *UserInfoHandler {
h.claimsReader = r
return h
}
// UserInfo serves GET and POST /userinfo per OIDC Core §5.3.
func (h *UserInfoHandler) UserInfo(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
// 1. Extract Bearer token per RFC 6750
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// RFC 6750 §3.1: realm-only challenge when no token is present; no error= parameter.
w.Header().Set("WWW-Authenticate", `Bearer realm="anekdote-auth"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "invalid_request",
"error_description": "Authorization header is required",
})
return
}
const prefix = "Bearer "
if !strings.HasPrefix(authHeader, prefix) || len(authHeader) == len(prefix) {
// RFC 6750 §3.1/§3.2: 4xx responses from protected resources must include WWW-Authenticate.
w.Header().Set("WWW-Authenticate", `Bearer realm="anekdote-auth"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "invalid_request",
"error_description": "malformed Authorization header",
})
return
}
tokenStr := authHeader[len(prefix):]
// 2. Parse and verify JWT with kid-based keyFunc + explicit algorithm allowlist
parsedToken, err := jwt.ParseWithClaims(tokenStr, jwt.MapClaims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, errors.New("unexpected signing method")
}
kid, _ := token.Header["kid"].(string)
if kid != h.keyStore.KeyID {
return nil, errors.New("unknown kid")
}
return h.keyStore.PublicKey, nil
},
jwt.WithValidMethods([]string{"RS256"}),
)
if err != nil || !parsedToken.Valid {
h.writeTokenError(w, "invalid_token", "token validation failed")
return
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
h.writeTokenError(w, "invalid_token", "invalid claims")
return
}
// 3. Check revocation — fail closed on Redis error to prevent claim disclosure
jti, _ := claims["jti"].(string)
if jti == "" {
h.writeTokenError(w, "invalid_token", "missing jti")
return
}
revoked, revErr := h.revStore.IsRevoked(r.Context(), jti)
if revErr != nil || revoked {
h.writeTokenError(w, "invalid_token", "token revoked")
return
}
// 4. Extract sub — empty sub or sub == aud means client_credentials (no user context).
// RFC 9068 sets sub = client_id for service-account tokens; reject those at userinfo.
sub, _ := claims["sub"].(string)
aud, _ := claims["aud"].(string)
if sub == "" || sub == aud {
h.writeTokenError(w, "invalid_token", "no user context")
return
}
userID := sub
// 5. Check tombstone — deleted users are rejected before the DB lookup.
if h.tombstoneDB != nil {
n, err := h.tombstoneDB.Exists(r.Context(), "deleted:user:"+userID).Result()
if err != nil || n > 0 {
h.writeTokenError(w, "invalid_token", "user not found")
return
}
}
// 6. Fetch user; check for disabled/deleted account
user, lookupErr := h.userStore.GetByID(userID)
if lookupErr != nil || user == nil {
h.writeTokenError(w, "invalid_token", "user not found")
return
}
if user.DisabledAt != nil {
h.writeTokenError(w, "invalid_token", "account disabled")
return
}
// 7. Build response from scope using exact-word matching
scope, _ := claims["scope"].(string)
scopeSet := make(map[string]bool)
for _, s := range strings.Fields(scope) {
scopeSet[s] = true
}
resp := map[string]interface{}{
"sub": user.ID,
}
if scopeSet["profile"] {
resp["updated_at"] = user.UpdatedAt.Unix()
if user.Name != "" {
resp["name"] = user.Name
}
if user.Username != "" {
resp["preferred_username"] = user.Username
}
}
if scopeSet["email"] {
resp["email"] = user.Email
resp["email_verified"] = user.IsVerified
}
// 8. Inject custom claims for destination="userinfo"
if h.claimsReader != nil {
clientID, _ := claims["aud"].(string)
if clientID != "" {
orgID, _ := claims["org_id"].(string)
orgRole, _ := claims["org_role"].(string)
custom, claimsErr := h.claimsReader.GetCustomClaimsForContext(r.Context(), clientID, scope, "userinfo", postgres.CustomClaimContext{
UserID: user.ID,
Email: user.Email,
Name: user.Name,
Username: user.Username,
OrgID: orgID,
OrgRole: orgRole,
})
if claimsErr != nil {
// OIDC Core §5.3: server_error maps to 500, not 401.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "server_error",
"error_description": "failed to load custom claims",
})
return
}
for k, v := range custom {
if _, reserved := reservedUserInfoClaims[strings.ToLower(k)]; reserved {
continue
}
resp[k] = v
}
}
}
// Write response with required OIDC/RFC 6749 headers
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(resp)
}
func (h *UserInfoHandler) writeTokenError(w http.ResponseWriter, code, desc string) {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s" error_description="%s"`, code, desc))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": code,
"error_description": desc,
})
}
package handlers
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
redisstore "github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/julienschmidt/httprouter"
"golang.org/x/text/unicode/norm"
)
var usernameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9_]{1,28}[a-z0-9]$`)
type UsernameHandler struct {
userStore *postgres.UserStore
bloom *redisstore.UsernameBloom
}
func NewUsernameHandler(userStore *postgres.UserStore, bloom *redisstore.UsernameBloom) *UsernameHandler {
return &UsernameHandler{userStore: userStore, bloom: bloom}
}
// Check handles GET /api/username-check?username=...
// Returns {"available": true/false, "reason": "..."} — never 4xx unless the param is missing.
func (h *UsernameHandler) Check(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
username := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("username")))
type result struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
w.Header().Set("Content-Type", "application/json")
if username == "" {
json.NewEncoder(w).Encode(result{Available: false, Reason: "required"})
return
}
if !usernameRegex.MatchString(username) {
json.NewEncoder(w).Encode(result{Available: false, Reason: "invalid"})
return
}
taken, err := h.isTaken(r.Context(), username)
if err != nil || taken {
json.NewEncoder(w).Encode(result{Available: false, Reason: "taken"})
return
}
json.NewEncoder(w).Encode(result{Available: true})
}
// Suggestions handles GET /api/username-suggestions?name=...
// Returns {"suggestions": ["user1", "user2", ...]} — up to 5 available candidates.
func (h *UsernameHandler) Suggestions(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
name := strings.TrimSpace(r.URL.Query().Get("name"))
type result struct {
Suggestions []string `json:"suggestions"`
}
w.Header().Set("Content-Type", "application/json")
if name == "" {
json.NewEncoder(w).Encode(result{Suggestions: []string{}})
return
}
candidates := generateCandidates(name)
var available []string
for _, c := range candidates {
if len(available) >= 5 {
break
}
taken, err := h.isTaken(r.Context(), c)
if err == nil && !taken {
available = append(available, c)
}
}
if available == nil {
available = []string{}
}
json.NewEncoder(w).Encode(result{Suggestions: available})
}
// isTaken uses the bloom filter as a fast negative gate, then confirms with the DB.
func (h *UsernameHandler) isTaken(ctx context.Context, username string) (bool, error) {
if h.bloom != nil {
might, err := h.bloom.MightExist(ctx, username)
if err == nil && !might {
return false, nil
}
}
return h.userStore.IsUsernameTaken(ctx, username)
}
// generateCandidates derives username candidates from a display name.
func generateCandidates(displayName string) []string {
base := slugify(displayName)
if base == "" {
return nil
}
parts := strings.Fields(slugify(displayName))
var candidates []string
seen := map[string]bool{}
add := func(s string) {
s = strings.ToLower(s)
s = strings.Trim(s, "_.")
if len(s) >= 3 && len(s) <= 30 && usernameRegex.MatchString(s) && !seen[s] {
seen[s] = true
candidates = append(candidates, s)
}
}
first := ""
last := ""
if len(parts) > 0 {
first = parts[0]
}
if len(parts) > 1 {
last = parts[len(parts)-1]
}
// Variants from name components
if first != "" && last != "" {
add(first + last)
add(first + "_" + last)
add(first + "." + last)
add(string([]rune(first)[0:1]) + last)
add(first + string([]rune(last)[0:1]))
} else if first != "" {
add(first)
}
add(base)
// Numbered variants
for _, suffix := range numberedSuffixes(8) {
if len(candidates) >= 20 {
break
}
if first != "" && last != "" {
add(first + last + suffix)
add(first + "_" + last + suffix)
} else if first != "" {
add(first + suffix)
}
}
return candidates
}
func numberedSuffixes(n int) []string {
out := make([]string, n)
for i := range out {
// mix of small numbers and random 2-digit numbers for variety
if i < 4 {
out[i] = fmt.Sprintf("%d", i+1)
} else {
out[i] = fmt.Sprintf("%d", rand.IntN(90)+10)
}
}
return out
}
// slugify converts a display name to a safe lowercase ASCII slug.
func slugify(s string) string {
// NFKD-normalize to break accented characters into base + combining marks
s = norm.NFKD.String(s)
var b strings.Builder
prev := '_'
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prev = r
case r >= 'A' && r <= 'Z':
b.WriteRune(r + 32)
prev = r + 32
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '.':
if prev != '_' && prev != '.' {
b.WriteRune('_')
}
prev = '_'
// skip combining marks and other non-ASCII
}
}
return strings.Trim(b.String(), "_.")
}
package idgen
import (
"crypto/rand"
"strings"
"github.com/oklog/ulid/v2"
)
const (
PrefixUser = "usr"
PrefixOrg = "org"
PrefixClient = "cli"
PrefixRequest = "req"
PrefixAudit = "log"
)
func new(prefix string) string {
id := ulid.MustNew(ulid.Now(), rand.Reader)
return prefix + "_" + strings.ToLower(id.String())
}
// Prefix returns the type prefix of an ID (e.g. "usr" for "usr_01...").
// Returns "" for IDs with no recognizable prefix.
func Prefix(id string) string {
if idx := strings.Index(id, "_"); idx > 0 {
return id[:idx]
}
return ""
}
func NewUserID() string { return new(PrefixUser) }
func NewOrgID() string { return new(PrefixOrg) }
func NewClientID() string { return new(PrefixClient) }
func NewRequestID() string { return new(PrefixRequest) }
func NewAuditID() string { return new(PrefixAudit) }
package mailer
import (
"bytes"
"context"
"strconv"
"github.com/iabhishekrajput/anekdote-auth/internal/config"
uiemail "github.com/iabhishekrajput/anekdote-auth/web/ui/email"
"github.com/wneessen/go-mail"
)
type Mailer struct {
config *config.Config
client *mail.Client
}
func NewMailer(cfg *config.Config) (*Mailer, error) {
port, err := strconv.Atoi(cfg.SMTPPort)
if err != nil {
port = 587
}
opts := []mail.Option{
mail.WithPort(port),
mail.WithSMTPAuth(mail.SMTPAuthPlain),
mail.WithUsername(cfg.SMTPUsername),
mail.WithPassword(cfg.SMTPPassword),
}
switch {
case cfg.SMTPInsecureSkipVerify:
// Local dev (e.g. Mailpit): plain SMTP, no TLS negotiation.
opts = append(opts, mail.WithTLSPolicy(mail.NoTLS))
case port == 465:
// SMTPS: implicit SSL/TLS on port 465 (e.g. Resend, SendGrid).
opts = append(opts, mail.WithSSL())
default:
// STARTTLS: explicit TLS upgrade (e.g. port 587).
opts = append(opts, mail.WithTLSPolicy(mail.TLSMandatory))
}
client, err := mail.NewClient(cfg.SMTPHost, opts...)
if err != nil {
return nil, err
}
return &Mailer{
config: cfg,
client: client,
}, nil
}
func (m *Mailer) SendPasswordReset(ctx context.Context, toEmail, resetLink string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmail); err != nil {
return err
}
msg.Subject("Password Reset - anekdote")
var body bytes.Buffer
if err := uiemail.PasswordResetEmail(resetLink).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendOrgInvite(ctx context.Context, toEmail, orgName, inviterEmail, acceptURL string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmail); err != nil {
return err
}
msg.Subject("You're invited to " + orgName + " - anekdote")
var body bytes.Buffer
if err := uiemail.OrgInviteEmail(orgName, inviterEmail, acceptURL).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendOwnershipTransfer(ctx context.Context, toEmail, orgName, orgSlug, appURL string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmail); err != nil {
return err
}
msg.Subject("You are now the owner of " + orgName + " - anekdote")
orgURL := appURL + "/account/orgs/" + orgSlug
var body bytes.Buffer
if err := uiemail.OwnershipTransferEmail(orgName, orgURL).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendClientGrantRequest(ctx context.Context, toEmails []string, clientName, requesterOrgName, requestsURL string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmails...); err != nil {
return err
}
msg.Subject(requesterOrgName + " is requesting access to " + clientName + " - anekdote")
var body bytes.Buffer
if err := uiemail.ClientGrantRequestEmail(clientName, requesterOrgName, requestsURL).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendGrantApproved(ctx context.Context, toEmails []string, clientName, requesterOrgName, clientsURL string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmails...); err != nil {
return err
}
msg.Subject("Access to " + clientName + " approved - anekdote")
var body bytes.Buffer
if err := uiemail.GrantApprovedEmail(clientName, requesterOrgName, clientsURL).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendGrantDenied(ctx context.Context, toEmails []string, clientName, requesterOrgName string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmails...); err != nil {
return err
}
msg.Subject("Access request for " + clientName + " denied - anekdote")
var body bytes.Buffer
if err := uiemail.GrantDeniedEmail(clientName, requesterOrgName).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendGrantRevoked(ctx context.Context, toEmails []string, clientName, orgName string, adminRevoke bool, reason string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmails...); err != nil {
return err
}
msg.Subject(orgName + "'s access to " + clientName + " removed - anekdote")
var body bytes.Buffer
if err := uiemail.GrantRevokedEmail(clientName, orgName, adminRevoke, reason).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendSecretRotated(ctx context.Context, toEmails []string, clientName, orgName string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmails...); err != nil {
return err
}
msg.Subject("Client secret rotated for " + clientName + " - anekdote")
var body bytes.Buffer
if err := uiemail.SecretRotatedEmail(clientName, orgName).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
func (m *Mailer) SendOTP(ctx context.Context, toEmail, otp string) error {
msg := mail.NewMsg()
if err := msg.From(m.config.SMTPFrom); err != nil {
return err
}
if err := msg.To(toEmail); err != nil {
return err
}
msg.Subject("Verify Your Email - anekdote")
var body bytes.Buffer
if err := uiemail.VerifyEmailOTPEmail(otp).Render(ctx, &body); err != nil {
return err
}
msg.SetBodyString(mail.TypeTextHTML, body.String())
return m.client.DialAndSendWithContext(ctx, msg)
}
package middleware
import (
"context"
"net/http"
"net/url"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/iabhishekrajput/anekdote-auth/internal/types"
"github.com/iabhishekrajput/anekdote-auth/internal/web"
"github.com/julienschmidt/httprouter"
)
// RequireAuth is a middleware that enforces an active user session.
func RequireAuth(sessionStore *redis.SessionStore, next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID, err := sessionStore.GetUserFromSession(r)
if err != nil {
// No valid session, redirect to login
http.Redirect(w, r, "/login?req="+r.URL.Path, http.StatusFound)
return
}
// Inject User ID into request context
ctx := context.WithValue(r.Context(), types.UserContextKey, userID)
r = r.WithContext(ctx)
next(w, r, ps)
}
}
// RequireAdmin enforces that the requester is both authenticated and an admin.
// Admin status is determined solely by user.IsAdmin in the database.
// Injects userID and adminRole into context for downstream handlers.
func RequireAdmin(sessionStore *redis.SessionStore, userStore *postgres.UserStore, next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID, err := sessionStore.GetUserFromSession(r)
if err != nil {
http.Redirect(w, r, "/login?req="+r.URL.Path, http.StatusFound)
return
}
user, err := userStore.GetByID(userID)
if err != nil || !user.IsAdmin {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), types.UserContextKey, userID)
ctx = context.WithValue(ctx, types.AdminRoleContextKey, user.AdminRole)
r = r.WithContext(ctx)
next(w, r, ps)
}
}
// RequireRole wraps a handler that is already behind RequireAdmin and enforces
// that the admin's role is one of the allowed roles. Must be applied after
// RequireAdmin so that AdminRoleContextKey is already set.
func RequireRole(next httprouter.Handle, roles ...string) httprouter.Handle {
allowed := make(map[string]bool, len(roles))
for _, role := range roles {
allowed[role] = true
}
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
adminRole, _ := r.Context().Value(types.AdminRoleContextKey).(string)
if !allowed[adminRole] {
http.Error(w, "403 Forbidden — insufficient admin role", http.StatusForbidden)
return
}
next(w, r, ps)
}
}
// InjectAdminStatus reads the userID already injected by RequireAuth and stores isAdmin bool in context.
// Also enforces DisabledAt — a disabled user with a live session is redirected to /login immediately.
// Must run inside RequireAuth in the chain — unauthenticated requests are rejected before reaching this.
func InjectAdminStatus(userStore *postgres.UserStore, next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
isAdmin := false
if userID, ok := r.Context().Value(types.UserContextKey).(string); ok {
if user, err := userStore.GetByID(userID); err == nil {
if user.DisabledAt != nil {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Your account has been disabled"), http.StatusFound)
return
}
if user.DeletedAt != nil {
web.ClearSessionCookie(w, r)
http.Redirect(w, r, "/login?error="+url.QueryEscape("Account not found"), http.StatusFound)
return
}
isAdmin = user.IsAdmin
} else {
// GetByID returns ErrUserNotFound for deleted users (deleted_at IS NULL filter).
// Clear the dangling session so subsequent requests don't repeat the DB lookup.
web.ClearSessionCookie(w, r)
http.Redirect(w, r, "/login?error="+url.QueryEscape("Account not found"), http.StatusFound)
return
}
}
ctx := context.WithValue(r.Context(), types.IsAdminContextKey, isAdmin)
next(w, r.WithContext(ctx), ps)
}
}
// RedirectIfAuthenticated is a middleware that redirects already logged-in users away from auth pages.
func RedirectIfAuthenticated(sessionStore *redis.SessionStore, next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
_, err := sessionStore.GetUserFromSession(r)
if err == nil {
// User is already logged in, redirect to account
http.Redirect(w, r, "/account", http.StatusFound)
return
}
next(w, r, ps)
}
}
package middleware
import (
"log/slog"
"net/http"
"time"
)
// statusRecorder wraps http.ResponseWriter to capture the written status code.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (sr *statusRecorder) WriteHeader(code int) {
sr.status = code
sr.ResponseWriter.WriteHeader(code)
}
// RequestLogger logs each inbound request: method, path, status, duration, and remote addr.
// It wraps any http.Handler and is placed outermost in the middleware chain so it covers
// CSRF, security headers, rate limiting, and all route handlers.
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
package middleware
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-redis/redis/v8"
"github.com/iabhishekrajput/anekdote-auth/internal/web"
"github.com/julienschmidt/httprouter"
)
// SecurityHeadersMiddleware adds standard web security headers to responses
func SecurityHeadersMiddleware(corsAllowed string) func(httprouter.Handle) httprouter.Handle {
return func(next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com")
// Configured CORS headers for OIDC/OAuth2 APIs
w.Header().Set("Access-Control-Allow-Origin", corsAllowed)
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, PUT, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization")
w.Header().Add("Vary", "Origin")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r, ps)
}
}
}
// RateLimitMiddleware provides a basic Redis-backed fixed-window rate limiter
func RateLimitMiddleware(client *redis.Client, prefix string, limit int, window time.Duration, next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.Header.Get("X-Real-IP")
}
if clientIP == "" {
clientIP = r.RemoteAddr
}
key := "rate_limit:" + prefix + ":" + clientIP
ctx := context.Background()
// Increment request count
count, err := client.Incr(ctx, key).Result()
if err != nil {
redirectErr(w, r, "Internal router error")
return
}
// Set expiry on first request in window
if count == 1 {
client.Expire(ctx, key, window)
}
if count > int64(limit) {
// API paths (bearer-auth, no session) get a JSON 429 with Retry-After.
// All other paths get the legacy redirect-with-error behaviour.
if strings.HasPrefix(r.URL.Path, "/api/") || r.Header.Get("Authorization") != "" {
ttl, _ := client.TTL(ctx, key).Result()
if ttl <= 0 {
ttl = window
}
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(ttl.Seconds()+0.5)))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"error":"rate limit exceeded","retry_after":%d}`, int(ttl.Seconds()+0.5))
return
}
redirectErr(w, r, "Rate limit exceeded. Please try again later.")
return
}
next(w, r, ps)
}
}
func redirectErr(w http.ResponseWriter, r *http.Request, errMsg string) {
ref := r.Referer()
if ref == "" {
ref = r.URL.Path
}
// Collapse the (attacker-influenceable) Referer to a same-origin path so
// this error redirect can't be turned into an open redirect.
u, err := url.Parse(web.SafeLocalRedirect(ref, "/"))
if err != nil {
u = &url.URL{Path: "/"}
}
q := u.Query()
q.Set("error", errMsg)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}
// Chain allows wrapping a handler in multiple middlewares easily
func Chain(handler httprouter.Handle, middlewares ...func(httprouter.Handle) httprouter.Handle) httprouter.Handle {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
package server
import (
"log/slog"
"net/http"
"time"
"github.com/go-redis/redis/v8"
"github.com/iabhishekrajput/anekdote-auth/internal/config"
"github.com/iabhishekrajput/anekdote-auth/internal/handlers"
"github.com/iabhishekrajput/anekdote-auth/internal/middleware"
pgstore "github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
redisstore "github.com/iabhishekrajput/anekdote-auth/internal/store/redis"
"github.com/julienschmidt/httprouter"
)
func NewRouter(
cfg *config.Config,
identH *handlers.IdentityHandler,
oauthH *handlers.OAuth2Handler,
discH *handlers.DiscoveryHandler,
accountH *handlers.AccountHandler,
orgH *handlers.OrgHandler,
adminH *handlers.AdminHandler,
probeH *handlers.ProbeHandler,
userInfoH *handlers.UserInfoHandler,
mgmtH *handlers.ManagementHandler,
usernameH *handlers.UsernameHandler,
sessionStore *redisstore.SessionStore,
userStore *pgstore.UserStore,
redisClient *redis.Client,
) *httprouter.Router {
router := httprouter.New()
// Apply Middlewares
secure := func(h httprouter.Handle) httprouter.Handle {
return middleware.Chain(h,
middleware.SecurityHeadersMiddleware(cfg.CORSAllowedOrigins),
func(next httprouter.Handle) httprouter.Handle {
return middleware.RateLimitMiddleware(redisClient, "global", 100, time.Minute, next)
},
)
}
authRateLimit := func(h httprouter.Handle) httprouter.Handle {
return middleware.Chain(h, func(next httprouter.Handle) httprouter.Handle {
return middleware.RateLimitMiddleware(redisClient, "auth", 10, time.Minute, next)
})
}
apiRateLimit := func(h httprouter.Handle) httprouter.Handle {
return middleware.Chain(h, func(next httprouter.Handle) httprouter.Handle {
return middleware.RateLimitMiddleware(redisClient, "api", 20, time.Minute, next)
})
}
secureUnauth := func(h httprouter.Handle) httprouter.Handle {
return secure(authRateLimit(middleware.RedirectIfAuthenticated(sessionStore, h)))
}
requireAdmin := func(h httprouter.Handle) httprouter.Handle {
return secure(middleware.RequireAdmin(sessionStore, userStore, h))
}
// requireSuperAdmin wraps requireAdmin with an additional role check.
// Use for all mutation routes that a readonly or org_admin should not access.
requireSuperAdmin := func(h httprouter.Handle) httprouter.Handle {
return requireAdmin(middleware.RequireRole(h, "superadmin"))
}
// requireSuperOrOrgAdmin allows both superadmin and org_admin roles.
requireSuperOrOrgAdmin := func(h httprouter.Handle) httprouter.Handle {
return requireAdmin(middleware.RequireRole(h, "superadmin", "org_admin"))
}
// withAuth chains RequireAuth then InjectAdminStatus so account/org handlers
// always have both userID and isAdmin available in context.
withAuth := func(h httprouter.Handle) httprouter.Handle {
return secure(middleware.RequireAuth(sessionStore, middleware.InjectAdminStatus(userStore, h)))
}
withAuthRateLimit := func(h httprouter.Handle) httprouter.Handle {
return secure(authRateLimit(middleware.RequireAuth(sessionStore, middleware.InjectAdminStatus(userStore, h))))
}
// 1. Identity Endpoints (UI / Form Submissions)
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
http.Redirect(w, r, "/login", http.StatusFound)
})
router.GET("/register", secureUnauth(identH.RegisterFunc))
router.POST("/register", secureUnauth(identH.RegisterFunc))
router.GET("/login", secureUnauth(identH.LoginFunc))
router.POST("/login", secureUnauth(identH.LoginFunc))
router.GET("/verify-email", secureUnauth(identH.VerifyEmailFunc))
router.POST("/verify-email", secureUnauth(identH.VerifyEmailFunc))
router.POST("/verify-email/resend", authRateLimit(identH.ResendOTPFunc))
router.GET("/resend-verification", secureUnauth(identH.ResendVerificationFunc))
router.POST("/resend-verification", authRateLimit(identH.ResendVerificationFunc))
router.GET("/forgot-password", secureUnauth(identH.ForgotPasswordFunc))
router.POST("/forgot-password", secureUnauth(identH.ForgotPasswordFunc))
router.GET("/reset-password", secureUnauth(identH.ResetPasswordFunc))
router.POST("/reset-password", secureUnauth(identH.ResetPasswordFunc))
router.POST("/logout", secure(identH.LogoutFunc))
router.GET("/account", withAuth(accountH.ViewAccount))
router.POST("/account/profile", withAuth(accountH.UpdateProfile))
router.POST("/account/password", withAuth(accountH.UpdatePassword))
// Org routes — /join is the public accept route (unauthenticated invite link)
router.GET("/join", secure(orgH.AcceptInvite))
router.GET("/account/orgs", withAuth(orgH.ListOrgs))
router.POST("/account/orgs", withAuthRateLimit(orgH.CreateOrg))
router.GET("/account/orgs/:slug", withAuth(orgH.OrgDetail))
router.GET("/account/orgs/:slug/clients", withAuth(orgH.OrgClients))
router.GET("/account/orgs/:slug/explore", withAuth(orgH.ExploreApps))
router.POST("/account/orgs/:slug/invites", withAuthRateLimit(orgH.SendInvite))
router.POST("/account/orgs/:slug/invites/:token/revoke", withAuthRateLimit(orgH.RevokeInvite))
router.POST("/account/orgs/:slug/members/:userID/role", withAuthRateLimit(orgH.ChangeMemberRole))
router.POST("/account/orgs/:slug/members/:userID/remove", withAuthRateLimit(orgH.RemoveMember))
router.POST("/account/orgs/:slug/leave", withAuthRateLimit(orgH.LeaveOrg))
router.POST("/account/orgs/:slug/transfer-ownership", withAuthRateLimit(orgH.TransferOwnershipAndLeave))
router.POST("/account/orgs/:slug/clients", withAuthRateLimit(orgH.RegisterClient))
router.POST("/account/orgs/:slug/clients/:clientID/delete", withAuthRateLimit(orgH.DeleteClient))
router.POST("/account/orgs/:slug/clients/:clientID/rotate-secret", withAuthRateLimit(orgH.RotateClientSecret))
router.POST("/account/orgs/:slug/delete", withAuthRateLimit(orgH.DeleteOrg))
router.POST("/account/orgs/:slug/grants", withAuthRateLimit(orgH.GrantClientAccess))
router.POST("/account/orgs/:slug/grants/:clientID/revoke", withAuthRateLimit(orgH.RevokeClientAccess))
router.POST("/account/orgs/:slug/clients/:clientID/requests/:requestID/approve", withAuthRateLimit(orgH.ApproveGrantRequest))
router.POST("/account/orgs/:slug/clients/:clientID/requests/:requestID/deny", withAuthRateLimit(orgH.DenyGrantRequest))
router.GET("/account/orgs/:slug/clients/:clientID/edit", withAuth(orgH.EditClient))
router.POST("/account/orgs/:slug/clients/:clientID/edit", withAuthRateLimit(orgH.EditClientPost))
router.GET("/account/orgs/:slug/clients/:clientID/claims", withAuth(orgH.ClientClaimsPage))
router.POST("/account/orgs/:slug/clients/:clientID/claims", withAuthRateLimit(orgH.ClientClaimsPost))
router.GET("/account/orgs/:slug/clients/:clientID/requests", withAuth(orgH.ClientRequestHistory))
router.POST("/account/orgs/:slug/clients/:clientID/grants/:orgID/scopes", withAuthRateLimit(orgH.SetGrantScopes))
router.GET("/account/apps", withAuth(orgH.DeveloperApps))
router.POST("/account/apps", withAuthRateLimit(orgH.RegisterDevApp))
router.POST("/account/delete", withAuthRateLimit(accountH.DeleteSelf))
// Admin routes — GET routes: any admin; mutations: role-scoped
router.GET("/admin", requireAdmin(adminH.Dashboard))
router.GET("/admin/users", requireAdmin(adminH.UserList))
router.GET("/admin/users/:id", requireAdmin(adminH.UserDetail))
router.POST("/admin/users/:id/disable", requireSuperAdmin(adminH.DisableUser))
router.POST("/admin/users/:id/enable", requireSuperAdmin(adminH.EnableUser))
router.POST("/admin/users/:id/promote", requireSuperAdmin(adminH.PromoteAdmin))
router.POST("/admin/users/:id/demote", requireSuperAdmin(adminH.DemoteAdmin))
router.POST("/admin/users/:id/admin-role", requireSuperAdmin(adminH.ChangeAdminRole))
router.GET("/admin/clients", requireAdmin(adminH.ClientList))
router.POST("/admin/clients/:id/delete", requireSuperAdmin(adminH.DeleteClient))
router.GET("/admin/clients/:id/claims", requireAdmin(adminH.AdminClientClaims))
router.POST("/admin/clients/:id/claims", requireSuperAdmin(adminH.AdminClientClaimsPost))
router.GET("/admin/orgs", requireAdmin(adminH.OrgList))
router.GET("/admin/orgs/:slug", requireAdmin(adminH.OrgDetail))
router.POST("/admin/orgs/:slug/members/:user_id/remove", requireSuperOrOrgAdmin(adminH.RemoveOrgMember))
router.POST("/admin/users/:id/delete", requireSuperAdmin(adminH.DeleteUser))
router.POST("/admin/orgs/:slug/delete", requireSuperAdmin(adminH.DeleteOrg))
router.GET("/admin/audit", requireAdmin(adminH.AuditLog))
router.GET("/admin/audit/export.csv", requireAdmin(adminH.ExportAuditCSV))
router.GET("/admin/grants", requireSuperAdmin(adminH.GrantList))
router.POST("/admin/grants/:clientID/:orgID/revoke", requireSuperAdmin(adminH.RevokeGrant))
// 2. OAuth2 Endpoints
router.GET("/authorize", secure(oauthH.Authorize))
router.POST("/authorize", secure(oauthH.Authorize)) // Depending on flow
router.POST("/token", secure(oauthH.Token))
router.POST("/revoke", secure(oauthH.Revoke))
// 3. Discovery (OIDC/JWKS) + UserInfo
router.GET("/.well-known/jwks.json", secure(discH.WellKnownJWKS))
router.GET("/.well-known/openid-configuration", secure(discH.OpenIDConfiguration))
// /userinfo: bearer-auth in handler, not RequireAuth (which uses cookie sessions).
// Both GET and POST required per OIDC Core §5.3.
router.GET("/userinfo", secure(userInfoH.UserInfo))
router.POST("/userinfo", secure(userInfoH.UserInfo))
// 4. Health/Readiness Probes
router.GET("/healthz", probeH.Health)
router.GET("/readyz", probeH.Ready)
// 5. Management API (bearer JWT, management audience + scope required)
apiSecure := func(h httprouter.Handle) httprouter.Handle {
return secure(apiRateLimit(h))
}
router.GET("/api/v1/clients/:id/claims", apiSecure(mgmtH.GetClientClaims))
router.PUT("/api/v1/clients/:id/claims", apiSecure(mgmtH.PutClientClaims))
router.PATCH("/api/v1/clients/:id/claims/*key", apiSecure(mgmtH.PatchClientClaim))
// 6. Username utilities (no auth required; CSRF-exempt via ^/api/ rule in main.go)
router.GET("/api/username-check", secure(apiRateLimit(usernameH.Check)))
router.GET("/api/username-suggestions", secure(apiRateLimit(usernameH.Suggestions)))
slog.Info("Router initialized with endpoints")
return router
}
package postgres
import (
"context"
"database/sql"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
)
// AuditAction is a typed constant for admin audit log action names.
type AuditAction string
const (
AuditActionDisableUser AuditAction = "disable_user"
AuditActionEnableUser AuditAction = "enable_user"
AuditActionDeleteClient AuditAction = "delete_client"
AuditActionRemoveOrgMember AuditAction = "remove_org_member"
AuditActionPromoteAdmin AuditAction = "promote_admin"
AuditActionDemoteAdmin AuditAction = "demote_admin"
AuditActionChangeAdminRole AuditAction = "change_admin_role"
AuditActionTransferOrgOwnership AuditAction = "transfer_org_ownership"
AuditActionDeleteUser AuditAction = "delete_user"
AuditActionDeleteOrg AuditAction = "delete_org"
AuditActionGrantOrgClient AuditAction = "grant_org_client"
AuditActionRevokeOrgClient AuditAction = "revoke_org_client"
AuditActionSetCustomClaims AuditAction = "set_custom_claims"
)
// AuditLogEntry is a single row from admin_audit_log.
type AuditLogEntry struct {
ID string
AdminID *string
Action AuditAction
TargetType string
TargetID string
IPAddress string
UserAgent string
CreatedAt time.Time
}
// AuditFilter holds optional filter parameters for audit log queries.
type AuditFilter struct {
AdminID *string
Action string
From *time.Time
To *time.Time
}
type AuditStore struct {
db *sql.DB
}
func NewAuditStore(db *sql.DB) *AuditStore {
return &AuditStore{db: db}
}
// Log inserts an audit entry. adminID may be empty string (logged as NULL).
func (s *AuditStore) Log(ctx context.Context, adminID string, action AuditAction, targetType, targetID, ipAddr, ua string) error {
var aid *string
if adminID != "" {
aid = &adminID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO admin_audit_log (admin_id, action, target_type, target_id, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
aid, string(action), targetType, targetID, ipAddr, ua,
)
return err
}
// buildFilterWhere constructs a WHERE clause and args slice for the given filter.
// The caller must already have consumed argN args before the returned args.
func buildFilterWhere(filter AuditFilter, startArgN int) (string, []any) {
var clauses []string
var args []any
n := startArgN
if filter.AdminID != nil {
n++
clauses = append(clauses, fmt.Sprintf("admin_id = $%d", n))
args = append(args, filter.AdminID)
}
if filter.Action != "" {
n++
clauses = append(clauses, fmt.Sprintf("action = $%d", n))
args = append(args, filter.Action)
}
if filter.From != nil {
n++
clauses = append(clauses, fmt.Sprintf("created_at >= $%d", n))
args = append(args, filter.From)
}
if filter.To != nil {
n++
clauses = append(clauses, fmt.Sprintf("created_at <= $%d", n))
args = append(args, filter.To)
}
if len(clauses) == 0 {
return "", args
}
return "WHERE " + strings.Join(clauses, " AND "), args
}
// CountAuditFiltered returns the total number of audit log entries matching the filter.
func (s *AuditStore) CountAuditFiltered(ctx context.Context, filter AuditFilter) (int, error) {
where, args := buildFilterWhere(filter, 0)
q := "SELECT COUNT(*) FROM admin_audit_log " + where
var count int
err := s.db.QueryRowContext(ctx, q, args...).Scan(&count)
return count, err
}
// ListAuditCursor returns audit entries with cursor-based pagination and optional filtering.
// Returns items, next-page cursor (empty = last page), and total filtered count.
func (s *AuditStore) ListAuditCursor(ctx context.Context, limit int, cursor *PageCursor, filter AuditFilter) ([]*AuditLogEntry, string, int, error) {
total, err := s.CountAuditFiltered(ctx, filter)
if err != nil {
return nil, "", 0, err
}
const selectCols = `SELECT id, admin_id, action, target_type, target_id, ip_address, user_agent, created_at
FROM admin_audit_log`
var conditions []string
var args []any
// cursor predicate (must come first so arg numbers align)
if cursor != nil {
conditions = append(conditions,
fmt.Sprintf("(created_at < $%d OR (created_at = $%d AND id < $%d))", 1, 1, 2))
args = append(args, cursor.CreatedAt, cursor.ID)
}
// filter predicates
filterWhere, filterArgs := buildFilterWhere(filter, len(args))
if filterWhere != "" {
// strip "WHERE " prefix and split into individual clauses
inner := strings.TrimPrefix(filterWhere, "WHERE ")
conditions = append(conditions, inner)
args = append(args, filterArgs...)
}
q := selectCols
if len(conditions) > 0 {
q += " WHERE " + strings.Join(conditions, " AND ")
}
q += fmt.Sprintf(" ORDER BY created_at DESC, id DESC LIMIT $%d", len(args)+1)
args = append(args, limit+1)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, "", total, err
}
defer rows.Close()
var entries []*AuditLogEntry
for rows.Next() {
e := &AuditLogEntry{}
var ipAddr, ua sql.NullString
if err := rows.Scan(&e.ID, &e.AdminID, &e.Action, &e.TargetType, &e.TargetID, &ipAddr, &ua, &e.CreatedAt); err != nil {
return nil, "", total, err
}
e.IPAddress = ipAddr.String
e.UserAgent = ua.String
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, "", total, err
}
nextCursor := ""
if len(entries) > limit {
last := entries[limit-1]
nextCursor = EncodeCursor(last.CreatedAt, last.ID)
entries = entries[:limit]
}
return entries, nextCursor, total, nil
}
// ExportAuditCSV streams all audit entries matching filter as CSV rows to w.
func (s *AuditStore) ExportAuditCSV(ctx context.Context, filter AuditFilter, w io.Writer) error {
where, args := buildFilterWhere(filter, 0)
q := `SELECT id, admin_id, action, target_type, target_id, ip_address, user_agent, created_at
FROM admin_audit_log ` + where + ` ORDER BY created_at DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return err
}
defer rows.Close()
cw := csv.NewWriter(w)
_ = cw.Write([]string{"id", "admin_id", "action", "target_type", "target_id", "ip_address", "user_agent", "created_at"})
for rows.Next() {
e := &AuditLogEntry{}
var ipAddr, ua sql.NullString
if err := rows.Scan(&e.ID, &e.AdminID, &e.Action, &e.TargetType, &e.TargetID, &ipAddr, &ua, &e.CreatedAt); err != nil {
return err
}
adminIDStr := ""
if e.AdminID != nil {
adminIDStr = *e.AdminID
}
_ = cw.Write([]string{
e.ID,
adminIDStr,
string(e.Action),
e.TargetType,
e.TargetID,
ipAddr.String,
ua.String,
e.CreatedAt.UTC().Format(time.RFC3339),
})
}
cw.Flush()
return rows.Err()
}
// DeleteOlderThan removes audit entries created before cutoff. Returns the number deleted.
func (s *AuditStore) DeleteOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM admin_audit_log WHERE created_at < $1`, cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
package postgres
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/go-oauth2/oauth2/v4"
goredis "github.com/go-redis/redis/v8"
"github.com/iabhishekrajput/anekdote-auth/internal/idgen"
"golang.org/x/crypto/bcrypt"
)
// ErrClientNotFound is returned when a client does not exist or does not belong to the given org.
var ErrClientNotFound = errors.New("client not found or not in org")
// ClaimDefinition is a structured custom claim row including policy metadata.
type ClaimDefinition struct {
Key string
ValueType string // "string", "number", "boolean"
Value string // raw string representation stored in DB
Destinations string // canonical sorted CSV (e.g. "access_token,id_token")
ScopeGate string // empty = always inject; otherwise a single scope name
SourceKind string // "static", "user_attribute", or "expression"
}
// CustomClaimContext carries token-time values used by dynamic claim definitions.
type CustomClaimContext struct {
UserID string
Email string
Name string
Username string
OrgID string
OrgRole string
}
// ErrGrantNotFound is returned when a client_org_grant row does not exist.
var ErrGrantNotFound = errors.New("grant not found")
// ErrGlobalClientUsesGrant is returned by DeleteOrgClient when the client is a
// multi-org client (org_id IS NULL). The caller should call RevokeOrgAccess instead.
var ErrGlobalClientUsesGrant = errors.New("client is multi-org; revoke grant instead of deleting")
// ErrGrantRequestNotPending is returned when an approve/deny arrives for a request
// that is no longer in 'pending' status (already resolved or consumed concurrently).
var ErrGrantRequestNotPending = errors.New("grant request is not pending")
// OrgGrantItem is a row from client_org_grants joined with client name info.
type OrgGrantItem struct {
ClientID string
ClientName string
GrantedAt time.Time
GrantedByEmail string
}
// ClientGrantItem is a row from client_org_grants joined with org info.
type ClientGrantItem struct {
OrgID string
OrgSlug string
OrgName string
GrantedAt time.Time
AllowedScopes *string // nil = no restriction; non-nil = space-separated allowed scope list
}
// DiscoverableClient represents a multi-org client available for connection.
type DiscoverableClient struct {
ID string
Name string
Domain string
Public bool
OwnerOrgID string
OwnerOrgName string
OwnerOrgSlug string
CreatedAt time.Time
}
// OrgClientInfo wraps the library's ClientInfo and adds OrgID.
// GetByID wraps ALL clients — existing clients with no org_id get OrgID=nil,
// so the type assertion in JWTGenerator always succeeds; nil is the safe fallback.
type OrgClientInfo struct {
oauth2.ClientInfo
OrgID *string
}
// VerifyPassword implements oauth2.ClientPasswordVerifier, delegating to the
// inner ClientInfo if it also implements the interface. This allows go-oauth2's
// manager to use bcrypt comparison instead of plaintext equality.
func (c *OrgClientInfo) VerifyPassword(plain string) bool {
if cp, ok := c.ClientInfo.(oauth2.ClientPasswordVerifier); ok {
return cp.VerifyPassword(plain)
}
return plain == ""
}
// GetName returns the human-readable client name, delegating to the inner ClientInfo.
func (c *OrgClientInfo) GetName() string {
type namer interface{ GetName() string }
if n, ok := c.ClientInfo.(namer); ok {
return n.GetName()
}
return ""
}
// HashedClient is an oauth2.ClientInfo whose secret column stores a bcrypt hash.
// GetSecret returns "" so go-oauth2 never uses the raw hash for equality checks;
// VerifyPassword is the authorised comparison path.
type HashedClient struct {
id string
name string
domain string
public bool
hash string
}
func (c *HashedClient) GetID() string { return c.id }
func (c *HashedClient) GetName() string { return c.name }
func (c *HashedClient) GetSecret() string { return "" }
func (c *HashedClient) GetDomain() string { return c.domain }
func (c *HashedClient) IsPublic() bool { return c.public }
func (c *HashedClient) GetUserID() string { return "" }
func (c *HashedClient) VerifyPassword(plain string) bool {
if c.hash == "" {
return plain == ""
}
return bcrypt.CompareHashAndPassword([]byte(c.hash), []byte(plain)) == nil
}
// OrgClient is a row from oauth2_clients visible to an org (owned or granted).
type OrgClient struct {
ID string
Name string
Domain string
Public bool
CreatedAt time.Time
IsGlobal bool // true when org_id IS NULL (multi-org client)
IsOwner bool // true when owner_org_id = queried org
// Populated for multi-org clients where IsOwner=true (loaded by handler).
ConnectedOrgs []*ClientGrantItem
PendingRequests []*GrantRequest
}
// GrantRequest is a row from client_access_requests.
type GrantRequest struct {
ID string
ClientID string
ClientName string
RequesterOrgID string
RequesterOrgSlug string
RequesterOrgName string
OwnerOrgID string
RequestedBy *string
Status string
RequestedAt time.Time
ResolvedAt *time.Time
}
// ClientStore implements oauth2.ClientStore interface using PostgreSQL
type ClientStore struct {
db *sql.DB
claimsCache *goredis.Client
claimsCacheTTL time.Duration
}
// NewClientStore creates a new PostgreSQL backed client store
func NewClientStore(db *sql.DB) *ClientStore {
return &ClientStore{db: db}
}
// WithClaimsCache enables Redis-backed caching for filtered claim definitions.
func (s *ClientStore) WithClaimsCache(rdb *goredis.Client, ttl time.Duration) *ClientStore {
s.claimsCache = rdb
s.claimsCacheTTL = ttl
return s
}
// GetByID retrieves a client by its ID, always wrapped in OrgClientInfo.
func (s *ClientStore) GetByID(ctx context.Context, id string) (oauth2.ClientInfo, error) {
var (
name string
secret string
domain string
public bool
orgID *string
)
err := s.db.QueryRowContext(ctx,
"SELECT name, secret, domain, public, org_id FROM oauth2_clients WHERE id = $1", id,
).Scan(&name, &secret, &domain, &public, &orgID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &OrgClientInfo{ClientInfo: &HashedClient{
id: id,
name: name,
domain: domain,
public: public,
hash: secret,
}, OrgID: orgID}, nil
}
// GetClientOrgID returns the org_id binding for a client, or nil if the client is not
// bound to any org (multi-org / public client). Returns sql.ErrNoRows if not found.
func (s *ClientStore) GetClientOrgID(ctx context.Context, clientID string) (*string, error) {
var orgID *string
err := s.db.QueryRowContext(ctx,
"SELECT org_id FROM oauth2_clients WHERE id = $1", clientID,
).Scan(&orgID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return orgID, nil
}
// ListOrgClients returns all clients visible to an org: owned single-org clients plus
// multi-org clients that have a grant for this org. Newest first.
func (s *ClientStore) ListOrgClients(ctx context.Context, orgID string) ([]*OrgClient, error) {
const q = `
SELECT c.id, c.name, c.domain, c.public, c.created_at,
(c.org_id IS NULL) AS is_global,
(c.owner_org_id = $1) AS is_owner
FROM oauth2_clients c WHERE c.org_id = $1
UNION
SELECT c.id, c.name, c.domain, c.public, c.created_at,
true AS is_global,
(c.owner_org_id = $1) AS is_owner
FROM oauth2_clients c
JOIN client_org_grants g ON g.client_id = c.id AND g.org_id = $1
WHERE c.org_id IS NULL
ORDER BY created_at DESC`
rows, err := s.db.QueryContext(ctx, q, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
var clients []*OrgClient
for rows.Next() {
c := &OrgClient{}
if err := rows.Scan(&c.ID, &c.Name, &c.Domain, &c.Public, &c.CreatedAt, &c.IsGlobal, &c.IsOwner); err != nil {
return nil, err
}
clients = append(clients, c)
}
return clients, rows.Err()
}
// CreateOrgClient registers a new OAuth2 client scoped to the given org.
// When multiOrg=true the client has org_id=NULL (accessible across orgs); the owner org is
// set via owner_org_id and an auto-grant row is created so the owner can use its own client.
// When multiOrg=false org_id=orgID (single-org, existing behaviour).
// For confidential clients it returns the plaintext secret; for public clients it returns "".
func (s *ClientStore) CreateOrgClient(ctx context.Context, orgID string, name, redirectURI string, public, multiOrg bool) (clientID, plainSecret string, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", "", err
}
defer tx.Rollback()
clientID = idgen.NewClientID()
var storedSecret string
if !public {
plainSecret = generateClientSecret()
hash, hashErr := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
if hashErr != nil {
return "", "", hashErr
}
storedSecret = string(hash)
}
var orgIDVal *string
if !multiOrg {
orgIDVal = &orgID
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO oauth2_clients (id, name, secret, domain, public, org_id, owner_org_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
clientID, name, storedSecret, redirectURI, public, orgIDVal, orgID,
); err != nil {
return "", "", err
}
// Auto-grant the owner org so it can use its own multi-org client without an approval step.
if _, err = tx.ExecContext(ctx,
`INSERT INTO client_org_grants (client_id, org_id, granted_by, granted_at)
VALUES ($1, $2, NULL, NOW())
ON CONFLICT (client_id, org_id) DO NOTHING`,
clientID, orgID,
); err != nil {
return "", "", err
}
if err = tx.Commit(); err != nil {
return "", "", err
}
return clientID, plainSecret, nil
}
// DeleteOrgClient removes a single-org client owned by orgID.
// Returns ErrGlobalClientUsesGrant when the client is a multi-org client (org_id IS NULL)
// and the org has a grant — the caller should call RevokeOrgAccess instead.
// Returns ErrClientNotFound when the client does not belong to this org at all.
func (s *ClientStore) DeleteOrgClient(ctx context.Context, clientID string, orgID string) error {
res, err := s.db.ExecContext(ctx,
"DELETE FROM oauth2_clients WHERE id = $1 AND org_id = $2",
clientID, orgID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n > 0 {
return nil
}
// Not a direct-org client — check if it's a multi-org client with a grant for this org.
isGlobal, err := s.IsGlobalClient(ctx, clientID)
if err != nil {
return err
}
if isGlobal {
ok, err := s.HasGrant(ctx, clientID, orgID)
if err != nil {
return err
}
if ok {
return ErrGlobalClientUsesGrant
}
}
return ErrClientNotFound
}
// RotateOrgClientSecret generates and stores a new secret for a confidential org client.
// Uses SELECT FOR UPDATE to prevent races. The secret is only returned after a successful commit.
func (s *ClientStore) RotateOrgClientSecret(ctx context.Context, clientID string, orgID string) (string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
var isPublic bool
err = tx.QueryRowContext(ctx,
`SELECT public FROM oauth2_clients
WHERE id = $1 AND (org_id = $2 OR owner_org_id = $2) FOR UPDATE`,
clientID, orgID,
).Scan(&isPublic)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrClientNotFound
}
if err != nil {
return "", err
}
if isPublic {
return "", errors.New("cannot rotate secret for a public client")
}
newSecret := generateClientSecret()
hash, hashErr := bcrypt.GenerateFromPassword([]byte(newSecret), bcrypt.DefaultCost)
if hashErr != nil {
return "", hashErr
}
if _, err = tx.ExecContext(ctx,
`UPDATE oauth2_clients SET secret = $1 WHERE id = $2 AND (org_id = $3 OR owner_org_id = $3)`,
string(hash), clientID, orgID,
); err != nil {
return "", err
}
if err = tx.Commit(); err != nil {
return "", err
}
return newSecret, nil
}
// ListDiscoverableClients returns multi-org clients from other orgs that the given org
// does not yet have access to or pending requests for, with cursor-based pagination.
// Returns clients, next-page cursor (empty = last page), total count, and error.
func (s *ClientStore) ListDiscoverableClients(ctx context.Context, excludeOrgID string, limit int, cursor *PageCursor) ([]*DiscoverableClient, string, int, error) {
const baseFilter = `
WHERE c.org_id IS NULL
AND c.owner_org_id != $1
AND NOT EXISTS (SELECT 1 FROM client_org_grants g WHERE g.client_id = c.id AND g.org_id = $1)
AND NOT EXISTS (SELECT 1 FROM client_access_requests r WHERE r.client_id = c.id AND r.requester_org_id = $1 AND r.status = 'pending')`
var total int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM oauth2_clients c`+baseFilter,
excludeOrgID,
).Scan(&total); err != nil {
return nil, "", 0, err
}
const selectCols = `
SELECT c.id, c.name, c.domain, c.public, c.owner_org_id, o.display_name, o.slug, c.created_at
FROM oauth2_clients c
JOIN organizations o ON c.owner_org_id = o.id`
var (
rows *sql.Rows
err error
)
if cursor == nil {
rows, err = s.db.QueryContext(ctx,
selectCols+baseFilter+` ORDER BY c.created_at DESC, c.id DESC LIMIT $2`,
excludeOrgID, limit+1,
)
} else {
rows, err = s.db.QueryContext(ctx,
selectCols+baseFilter+
` AND (c.created_at < $2 OR (c.created_at = $2 AND c.id < $3))`+
` ORDER BY c.created_at DESC, c.id DESC LIMIT $4`,
excludeOrgID, cursor.CreatedAt, cursor.ID, limit+1,
)
}
if err != nil {
return nil, "", total, err
}
defer rows.Close()
var clients []*DiscoverableClient
for rows.Next() {
c := &DiscoverableClient{}
if err := rows.Scan(&c.ID, &c.Name, &c.Domain, &c.Public, &c.OwnerOrgID, &c.OwnerOrgName, &c.OwnerOrgSlug, &c.CreatedAt); err != nil {
return nil, "", total, err
}
clients = append(clients, c)
}
if err := rows.Err(); err != nil {
return nil, "", total, err
}
nextCursor := ""
if len(clients) > limit {
last := clients[limit-1]
nextCursor = EncodeCursor(last.CreatedAt, last.ID)
clients = clients[:limit]
}
return clients, nextCursor, total, nil
}
// AdminClientItem is used by the admin panel for the client list view.
type AdminClientItem struct {
ID string
Name string
Domain string
Public bool
OrgSlug string
OrgName string
ClaimCount int
CreatedAt time.Time
}
// ListAllCursor returns OAuth2 clients using cursor-based pagination.
// Returns items, next-page cursor (empty = last page), and total count.
func (s *ClientStore) ListAllCursor(ctx context.Context, limit int, cursor *PageCursor) ([]*AdminClientItem, string, int, error) {
return s.ListAllCursorFiltered(ctx, limit, cursor, false)
}
// ListAllCursorFiltered returns OAuth2 clients with optional filtering to clients with claims.
func (s *ClientStore) ListAllCursorFiltered(ctx context.Context, limit int, cursor *PageCursor, withClaimsOnly bool) ([]*AdminClientItem, string, int, error) {
total, err := s.CountAllFiltered(ctx, withClaimsOnly)
if err != nil {
return nil, "", 0, err
}
const selectCols = `SELECT c.id, c.name, c.domain, c.public, c.created_at,
COALESCE(o.slug, '') AS org_slug, COALESCE(o.display_name, '') AS org_name,
COUNT(d.id) AS claim_count
FROM oauth2_clients c
LEFT JOIN organizations o ON o.id = c.org_id
LEFT JOIN client_claim_definitions d ON d.client_id = c.id`
where := ""
if withClaimsOnly {
where = ` WHERE EXISTS (SELECT 1 FROM client_claim_definitions cd WHERE cd.client_id = c.id)`
}
groupOrder := ` GROUP BY c.id, c.name, c.domain, c.public, c.created_at, o.slug, o.display_name`
var rows *sql.Rows
if cursor == nil {
rows, err = s.db.QueryContext(ctx,
selectCols+where+groupOrder+` ORDER BY c.created_at DESC, c.id DESC LIMIT $1`,
limit+1,
)
} else {
cursorFilter := ` WHERE c.created_at < $1 OR (c.created_at = $1 AND c.id < $2)`
if withClaimsOnly {
cursorFilter = ` WHERE EXISTS (SELECT 1 FROM client_claim_definitions cd WHERE cd.client_id = c.id)
AND (c.created_at < $1 OR (c.created_at = $1 AND c.id < $2))`
}
rows, err = s.db.QueryContext(ctx,
selectCols+cursorFilter+groupOrder+` ORDER BY c.created_at DESC, c.id DESC LIMIT $3`,
cursor.CreatedAt, cursor.ID, limit+1,
)
}
if err != nil {
return nil, "", total, err
}
defer rows.Close()
var clients []*AdminClientItem
for rows.Next() {
c := &AdminClientItem{}
if err := rows.Scan(&c.ID, &c.Name, &c.Domain, &c.Public, &c.CreatedAt, &c.OrgSlug, &c.OrgName, &c.ClaimCount); err != nil {
return nil, "", total, err
}
clients = append(clients, c)
}
if err := rows.Err(); err != nil {
return nil, "", total, err
}
nextCursor := ""
if len(clients) > limit {
last := clients[limit-1]
nextCursor = EncodeCursor(last.CreatedAt, last.ID)
clients = clients[:limit]
}
return clients, nextCursor, total, nil
}
// CountAll returns the total number of OAuth2 clients.
func (s *ClientStore) CountAll(ctx context.Context) (int, error) {
return s.CountAllFiltered(ctx, false)
}
// CountAllFiltered returns the total number of OAuth2 clients, optionally with claims only.
func (s *ClientStore) CountAllFiltered(ctx context.Context, withClaimsOnly bool) (int, error) {
var count int
q := `SELECT COUNT(*) FROM oauth2_clients`
if withClaimsOnly {
q = `SELECT COUNT(*) FROM oauth2_clients c WHERE EXISTS (SELECT 1 FROM client_claim_definitions d WHERE d.client_id = c.id)`
}
err := s.db.QueryRowContext(ctx, q).Scan(&count)
return count, err
}
// CountAllGrants returns the total number of active client_org_grants rows.
func (s *ClientStore) CountAllGrants(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM client_org_grants`).Scan(&count)
return count, err
}
// DeleteAny removes a client by ID regardless of org (admin-only operation).
func (s *ClientStore) DeleteAny(ctx context.Context, clientID string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM oauth2_clients WHERE id = $1`, clientID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrClientNotFound
}
return nil
}
// GrantOrgAccess adds a grant allowing clientID to be used by users of orgID.
// Idempotent — if the grant already exists it is a no-op.
func (s *ClientStore) GrantOrgAccess(ctx context.Context, clientID string, orgID, grantedBy string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO client_org_grants (client_id, org_id, granted_by, granted_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (client_id, org_id) DO NOTHING`,
clientID, orgID, grantedBy,
)
return err
}
// RevokeOrgAccess removes a grant row. The caller is responsible for blocklisting
// outstanding JTIs via the redis token index (oauth:user-org-tokens:{userID}:{orgID}).
func (s *ClientStore) RevokeOrgAccess(ctx context.Context, clientID string, orgID string) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM client_org_grants WHERE client_id = $1 AND org_id = $2`,
clientID, orgID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrGrantNotFound
}
return nil
}
// ListOrgsGrantedClient returns all orgs that have granted access to clientID.
func (s *ClientStore) ListOrgsGrantedClient(ctx context.Context, clientID string) ([]*ClientGrantItem, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT g.org_id, o.slug, o.display_name, g.granted_at, g.allowed_scopes
FROM client_org_grants g
JOIN organizations o ON o.id = g.org_id AND o.deleted_at IS NULL
WHERE g.client_id = $1
ORDER BY g.granted_at DESC`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*ClientGrantItem
for rows.Next() {
item := &ClientGrantItem{}
if err := rows.Scan(&item.OrgID, &item.OrgSlug, &item.OrgName, &item.GrantedAt, &item.AllowedScopes); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// ListOrgGrantedClients returns all clients that have been granted access to orgID.
func (s *ClientStore) ListOrgGrantedClients(ctx context.Context, orgID string) ([]*OrgGrantItem, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT g.client_id, c.name, g.granted_at, COALESCE(u.email, '')
FROM client_org_grants g
JOIN oauth2_clients c ON c.id = g.client_id
LEFT JOIN users u ON u.id = g.granted_by AND u.deleted_at IS NULL
WHERE g.org_id = $1
ORDER BY g.granted_at DESC`,
orgID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*OrgGrantItem
for rows.Next() {
item := &OrgGrantItem{}
if err := rows.Scan(&item.ClientID, &item.ClientName, &item.GrantedAt, &item.GrantedByEmail); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// ListUserEligibleOrgsForClient returns orgs where:
// 1. The client has a grant for the org (client_org_grants)
// 2. The user is an active member of the org
func (s *ClientStore) ListUserEligibleOrgsForClient(ctx context.Context, clientID string, userID string) ([]*ClientGrantItem, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT g.org_id, o.slug, o.display_name, g.granted_at, g.allowed_scopes
FROM client_org_grants g
JOIN organizations o ON o.id = g.org_id AND o.deleted_at IS NULL
JOIN org_memberships m ON m.org_id = g.org_id AND m.user_id = $2 AND m.removed_at IS NULL
WHERE g.client_id = $1
ORDER BY o.display_name ASC`,
clientID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*ClientGrantItem
for rows.Next() {
item := &ClientGrantItem{}
if err := rows.Scan(&item.OrgID, &item.OrgSlug, &item.OrgName, &item.GrantedAt, &item.AllowedScopes); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// GetGrantAllowedScopes returns the allowed_scopes restriction for a specific grant.
// Returns nil when no restriction is set (all scopes are allowed).
func (s *ClientStore) GetGrantAllowedScopes(ctx context.Context, clientID string, orgID string) (*string, error) {
var scopes *string
err := s.db.QueryRowContext(ctx,
`SELECT allowed_scopes FROM client_org_grants WHERE client_id = $1 AND org_id = $2`,
clientID, orgID,
).Scan(&scopes)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return scopes, err
}
// SetGrantScopes sets the allowed_scopes restriction for a specific grant.
// Pass an empty string to remove the restriction (set to NULL).
func (s *ClientStore) SetGrantScopes(ctx context.Context, clientID string, orgID string, scopes string) error {
var scopesVal *string
if scopes != "" {
scopesVal = &scopes
}
res, err := s.db.ExecContext(ctx,
`UPDATE client_org_grants SET allowed_scopes = $1 WHERE client_id = $2 AND org_id = $3`,
scopesVal, clientID, orgID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrGrantNotFound
}
return nil
}
// UserClientItem represents an OAuth2 client attributed to the org that owns it.
type UserClientItem struct {
ID string
Name string
Domain string
Public bool
IsGlobal bool
CreatedAt time.Time
OrgID string
OrgSlug string
OrgName string
}
// ListClientsForUser returns all OAuth2 clients owned by orgs where userID is owner or admin.
func (s *ClientStore) ListClientsForUser(ctx context.Context, userID string) ([]*UserClientItem, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.domain, c.public, (c.org_id IS NULL) AS is_global, c.created_at,
o.id, o.slug, o.display_name
FROM oauth2_clients c
JOIN organizations o ON o.id = c.owner_org_id AND o.deleted_at IS NULL
JOIN org_memberships m ON m.org_id = c.owner_org_id AND m.user_id = $1 AND m.removed_at IS NULL
WHERE m.role IN ('owner', 'admin')
ORDER BY c.created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*UserClientItem
for rows.Next() {
item := &UserClientItem{}
if err := rows.Scan(&item.ID, &item.Name, &item.Domain, &item.Public, &item.IsGlobal, &item.CreatedAt,
&item.OrgID, &item.OrgSlug, &item.OrgName); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// IsGlobalClient returns true when the client has org_id IS NULL (multi-org client).
func (s *ClientStore) IsGlobalClient(ctx context.Context, clientID string) (bool, error) {
var isGlobal bool
err := s.db.QueryRowContext(ctx,
`SELECT (org_id IS NULL) FROM oauth2_clients WHERE id = $1`,
clientID,
).Scan(&isGlobal)
if errors.Is(err, sql.ErrNoRows) {
return false, ErrClientNotFound
}
return isGlobal, err
}
// HasGrant reports whether clientID has an active grant for orgID.
func (s *ClientStore) HasGrant(ctx context.Context, clientID string, orgID string) (bool, error) {
var ok bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM client_org_grants WHERE client_id = $1 AND org_id = $2)`,
clientID, orgID,
).Scan(&ok)
return ok, err
}
// GetClientOwnerOrgID returns the owner_org_id of a client. Returns nil when unset.
func (s *ClientStore) GetClientOwnerOrgID(ctx context.Context, clientID string) (*string, error) {
var ownerOrgID *string
err := s.db.QueryRowContext(ctx,
`SELECT owner_org_id FROM oauth2_clients WHERE id = $1`,
clientID,
).Scan(&ownerOrgID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrClientNotFound
}
return ownerOrgID, err
}
// CreateGrantRequest inserts a pending client_access_requests row.
// Returns the new request. If a pending request already exists for this client+org pair,
// the conflict is ignored and nil,nil is returned (caller should redirect with "already pending").
func (s *ClientStore) CreateGrantRequest(ctx context.Context, clientID string, requesterOrgID, ownerOrgID, requestedBy string) (*GrantRequest, error) {
gr := &GrantRequest{}
requestID := idgen.NewRequestID()
err := s.db.QueryRowContext(ctx,
`INSERT INTO client_access_requests
(id, client_id, requester_org_id, owner_org_id, requested_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT DO NOTHING
RETURNING id, client_id, requester_org_id, owner_org_id, requested_by, status, requested_at`,
requestID, clientID, requesterOrgID, ownerOrgID, requestedBy,
).Scan(&gr.ID, &gr.ClientID, &gr.RequesterOrgID, &gr.OwnerOrgID, &gr.RequestedBy, &gr.Status, &gr.RequestedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return gr, nil
}
// GetGrantRequest fetches a single grant request by ID including client name and requester org info.
func (s *ClientStore) GetGrantRequest(ctx context.Context, requestID string) (*GrantRequest, error) {
gr := &GrantRequest{}
err := s.db.QueryRowContext(ctx,
`SELECT r.id, r.client_id, COALESCE(c.name,''), r.requester_org_id,
COALESCE(ro.slug,''), COALESCE(ro.display_name,''),
r.owner_org_id, r.requested_by, r.status, r.requested_at
FROM client_access_requests r
LEFT JOIN oauth2_clients c ON c.id = r.client_id
LEFT JOIN organizations ro ON ro.id = r.requester_org_id
WHERE r.id = $1`,
requestID,
).Scan(&gr.ID, &gr.ClientID, &gr.ClientName,
&gr.RequesterOrgID, &gr.RequesterOrgSlug, &gr.RequesterOrgName,
&gr.OwnerOrgID, &gr.RequestedBy, &gr.Status, &gr.RequestedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrClientNotFound
}
return gr, err
}
// ListGrantRequestsForClient returns pending grant requests for a client (owner's view).
func (s *ClientStore) ListGrantRequestsForClient(ctx context.Context, clientID string) ([]*GrantRequest, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT r.id, r.client_id, COALESCE(c.name,''), r.requester_org_id,
COALESCE(ro.slug,''), COALESCE(ro.display_name,''),
r.owner_org_id, r.requested_by, r.status, r.requested_at
FROM client_access_requests r
LEFT JOIN oauth2_clients c ON c.id = r.client_id
LEFT JOIN organizations ro ON ro.id = r.requester_org_id
WHERE r.client_id = $1 AND r.status = 'pending'
ORDER BY r.requested_at DESC`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanGrantRequests(rows)
}
// ListGrantRequestsForOrg returns pending grant requests made by an org (requester's view).
func (s *ClientStore) ListGrantRequestsForOrg(ctx context.Context, requesterOrgID string) ([]*GrantRequest, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT r.id, r.client_id, COALESCE(c.name,''), r.requester_org_id,
COALESCE(ro.slug,''), COALESCE(ro.display_name,''),
r.owner_org_id, r.requested_by, r.status, r.requested_at
FROM client_access_requests r
LEFT JOIN oauth2_clients c ON c.id = r.client_id
LEFT JOIN organizations ro ON ro.id = r.requester_org_id
WHERE r.requester_org_id = $1 AND r.status = 'pending'
ORDER BY r.requested_at DESC`,
requesterOrgID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanGrantRequests(rows)
}
func scanGrantRequests(rows *sql.Rows) ([]*GrantRequest, error) {
var results []*GrantRequest
for rows.Next() {
gr := &GrantRequest{}
if err := rows.Scan(&gr.ID, &gr.ClientID, &gr.ClientName,
&gr.RequesterOrgID, &gr.RequesterOrgSlug, &gr.RequesterOrgName,
&gr.OwnerOrgID, &gr.RequestedBy, &gr.Status, &gr.RequestedAt); err != nil {
return nil, err
}
results = append(results, gr)
}
return results, rows.Err()
}
// UpdateOrgClient updates the name and redirect URI of a client owned by ownerOrgID.
func (s *ClientStore) UpdateOrgClient(ctx context.Context, clientID string, ownerOrgID string, name, domain string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE oauth2_clients SET name = $1, domain = $2 WHERE id = $3 AND owner_org_id = $4`,
name, domain, clientID, ownerOrgID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrClientNotFound
}
return nil
}
// GetClientName returns the display name of a client by ID.
func (s *ClientStore) GetClientName(ctx context.Context, clientID string) (string, error) {
var name string
err := s.db.QueryRowContext(ctx, `SELECT name FROM oauth2_clients WHERE id = $1`, clientID).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrClientNotFound
}
return name, err
}
// ListAllGrantRequestsForClient returns all grant requests for a client across all
// statuses (pending, approved, denied), newest first, limit 50.
func (s *ClientStore) ListAllGrantRequestsForClient(ctx context.Context, clientID string) ([]*GrantRequest, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT r.id, r.client_id, COALESCE(c.name,''), r.requester_org_id,
COALESCE(ro.slug,''), COALESCE(ro.display_name,''),
r.owner_org_id, r.requested_by, r.status, r.requested_at
FROM client_access_requests r
LEFT JOIN oauth2_clients c ON c.id = r.client_id
LEFT JOIN organizations ro ON ro.id = r.requester_org_id
WHERE r.client_id = $1
ORDER BY r.requested_at DESC
LIMIT 50`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanGrantRequests(rows)
}
// AdminGrantRow is used by the admin grants panel.
type AdminGrantRow struct {
ClientID string
ClientName string
OwnerOrgName string
GrantedOrgName string
GrantedOrgID string
GrantedAt time.Time
}
// ListAllClientOrgGrants returns all grant rows across all clients for the admin panel,
// with owner and granted org info. Returns rows, total count, and error.
func (s *ClientStore) ListAllClientOrgGrants(ctx context.Context, limit, offset int) ([]*AdminGrantRow, int, error) {
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM client_org_grants`).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT g.client_id, c.name AS client_name,
co.display_name AS owner_org_name,
granted_org.display_name AS granted_org_name, g.org_id AS granted_org_id, g.granted_at
FROM client_org_grants g
JOIN oauth2_clients c ON c.id = g.client_id
JOIN organizations co ON co.id = c.owner_org_id
JOIN organizations granted_org ON granted_org.id = g.org_id
ORDER BY g.granted_at DESC
LIMIT $1 OFFSET $2`,
limit, offset,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var results []*AdminGrantRow
for rows.Next() {
row := &AdminGrantRow{}
if err := rows.Scan(&row.ClientID, &row.ClientName, &row.OwnerOrgName, &row.GrantedOrgName, &row.GrantedOrgID, &row.GrantedAt); err != nil {
return nil, 0, err
}
results = append(results, row)
}
return results, total, rows.Err()
}
// ApproveGrantRequest atomically transitions a pending request to 'approved' and
// creates the client_org_grants row. Binds clientID and ownerOrgID in the WHERE clause
// to prevent cross-client request hijacking. Returns the updated request row so the
// handler can send notification email without an extra query.
func (s *ClientStore) ApproveGrantRequest(ctx context.Context, requestID string, clientID string, ownerOrgID, resolvedBy string) (*GrantRequest, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
gr := &GrantRequest{}
err = tx.QueryRowContext(ctx,
`UPDATE client_access_requests
SET status='approved', resolved_at=NOW(), resolved_by=$4
WHERE id=$1 AND client_id=$2 AND owner_org_id=$3 AND status='pending'
RETURNING id, client_id, requester_org_id, owner_org_id, requested_by, status, requested_at`,
requestID, clientID, ownerOrgID, resolvedBy,
).Scan(&gr.ID, &gr.ClientID, &gr.RequesterOrgID, &gr.OwnerOrgID, &gr.RequestedBy, &gr.Status, &gr.RequestedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrGrantRequestNotPending
}
if err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO client_org_grants (client_id, org_id, granted_by, granted_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (client_id, org_id) DO NOTHING`,
gr.ClientID, gr.RequesterOrgID, resolvedBy,
); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return gr, nil
}
// DenyGrantRequest transitions a pending request to 'denied'. Binds clientID and ownerOrgID
// to prevent cross-client request hijacking. Returns the updated request row for notification email.
func (s *ClientStore) DenyGrantRequest(ctx context.Context, requestID string, clientID string, ownerOrgID, resolvedBy string) (*GrantRequest, error) {
gr := &GrantRequest{}
err := s.db.QueryRowContext(ctx,
`UPDATE client_access_requests
SET status='denied', resolved_at=NOW(), resolved_by=$4
WHERE id=$1 AND client_id=$2 AND owner_org_id=$3 AND status='pending'
RETURNING id, client_id, requester_org_id, owner_org_id, requested_by, status, requested_at`,
requestID, clientID, ownerOrgID, resolvedBy,
).Scan(&gr.ID, &gr.ClientID, &gr.RequesterOrgID, &gr.OwnerOrgID, &gr.RequestedBy, &gr.Status, &gr.RequestedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrGrantRequestNotPending
}
if err != nil {
return nil, err
}
return gr, nil
}
// GetCustomClaims returns filtered custom claims for a client.
func (s *ClientStore) GetCustomClaims(ctx context.Context, clientID, grantedScope, destination string) (map[string]any, error) {
return s.GetCustomClaimsForContext(ctx, clientID, grantedScope, destination, CustomClaimContext{})
}
// GetCustomClaimsForContext returns filtered custom claims with dynamic values resolved.
func (s *ClientStore) GetCustomClaimsForContext(ctx context.Context, clientID, grantedScope, destination string, claimCtx CustomClaimContext) (map[string]any, error) {
defs, err := s.filteredClaimDefinitions(ctx, clientID, grantedScope, destination)
if err != nil {
return nil, err
}
claims := make(map[string]any)
for _, def := range defs {
rawValue, ok := resolveClaimValue(def, claimCtx)
if !ok {
continue
}
switch def.ValueType {
case "string":
claims[def.Key] = rawValue
case "number":
var f float64
if _, err := fmt.Sscanf(rawValue, "%g", &f); err != nil {
return nil, fmt.Errorf("claim %q: invalid number value %q: %w", def.Key, rawValue, err)
}
claims[def.Key] = f
case "boolean":
claims[def.Key] = rawValue == "true"
}
}
return claims, nil
}
func (s *ClientStore) filteredClaimDefinitions(ctx context.Context, clientID, grantedScope, destination string) ([]ClaimDefinition, error) {
cacheKey := s.claimsCacheKey(clientID, grantedScope, destination)
if s.claimsCache != nil && s.claimsCacheTTL > 0 {
if raw, err := s.claimsCache.Get(ctx, cacheKey).Bytes(); err == nil {
var cached []ClaimDefinition
if json.Unmarshal(raw, &cached) == nil {
return cached, nil
}
}
}
rows, err := s.db.QueryContext(ctx,
`SELECT key, value_type, value, COALESCE(scope_gate,''), COALESCE(destinations,'token'), COALESCE(source_kind,'static')
FROM client_claim_definitions WHERE client_id = $1`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var defs []ClaimDefinition
for rows.Next() {
var d ClaimDefinition
if err := rows.Scan(&d.Key, &d.ValueType, &d.Value, &d.ScopeGate, &d.Destinations, &d.SourceKind); err != nil {
return nil, err
}
if !scopeMatches(d.ScopeGate, grantedScope) || !destMatches(d.Destinations, destination) {
continue
}
if d.SourceKind == "" {
d.SourceKind = "static"
}
defs = append(defs, d)
}
if err := rows.Err(); err != nil {
return nil, err
}
if s.claimsCache != nil && s.claimsCacheTTL > 0 {
if raw, err := json.Marshal(defs); err == nil {
_ = s.claimsCache.Set(ctx, cacheKey, raw, s.claimsCacheTTL).Err()
}
}
return defs, nil
}
func (s *ClientStore) claimsCacheKey(clientID, grantedScope, destination string) string {
return "claims:def:" + clientID + ":" + destination + ":" + strings.Join(strings.Fields(grantedScope), "+")
}
func resolveClaimValue(def ClaimDefinition, ctx CustomClaimContext) (string, bool) {
switch def.SourceKind {
case "", "static":
return def.Value, true
case "user_attribute":
return claimAttribute(def.Value, ctx)
case "expression":
return expandClaimExpression(def.Value, ctx), true
default:
return "", false
}
}
func claimAttribute(name string, ctx CustomClaimContext) (string, bool) {
switch name {
case "user.id":
return ctx.UserID, ctx.UserID != ""
case "user.email":
return ctx.Email, ctx.Email != ""
case "user.name":
return ctx.Name, ctx.Name != ""
case "user.username":
return ctx.Username, ctx.Username != ""
case "org.id":
return ctx.OrgID, ctx.OrgID != ""
case "org.role":
return ctx.OrgRole, ctx.OrgRole != ""
default:
return "", false
}
}
func expandClaimExpression(expr string, ctx CustomClaimContext) string {
replacements := map[string]string{
"{{user.id}}": ctx.UserID,
"{{user.email}}": ctx.Email,
"{{user.name}}": ctx.Name,
"{{user.username}}": ctx.Username,
"{{org.id}}": ctx.OrgID,
"{{org.role}}": ctx.OrgRole,
}
out := expr
for k, v := range replacements {
out = strings.ReplaceAll(out, k, v)
}
return out
}
// ListCustomClaims returns all claim definitions for a client (unfiltered, for UI display).
func (s *ClientStore) ListCustomClaims(ctx context.Context, clientID string) ([]ClaimDefinition, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT key, value_type, value, COALESCE(scope_gate,''), COALESCE(destinations,'token'), COALESCE(source_kind,'static')
FROM client_claim_definitions WHERE client_id = $1
ORDER BY key ASC`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var defs []ClaimDefinition
for rows.Next() {
var d ClaimDefinition
if err := rows.Scan(&d.Key, &d.ValueType, &d.Value, &d.ScopeGate, &d.Destinations, &d.SourceKind); err != nil {
return nil, err
}
defs = append(defs, d)
}
return defs, rows.Err()
}
// SetCustomClaims atomically replaces all claims for a client owned by ownerOrgID.
// Returns ErrClientNotFound when clientID does not exist or is not owned by ownerOrgID.
func (s *ClientStore) SetCustomClaims(ctx context.Context, clientID, ownerOrgID string, defs []ClaimDefinition) error {
ownerID, err := s.GetClientOwnerOrgID(ctx, clientID)
if err != nil {
return err
}
if ownerID == nil || *ownerID != ownerOrgID {
return ErrClientNotFound
}
err = s.replaceClaimRows(ctx, clientID, defs)
if err == nil {
s.invalidateClaimCache(ctx, clientID)
}
return err
}
// SetCustomClaimsAdmin atomically replaces all claims for any client without an org ownership check.
func (s *ClientStore) SetCustomClaimsAdmin(ctx context.Context, clientID string, defs []ClaimDefinition) error {
err := s.replaceClaimRows(ctx, clientID, defs)
if err == nil {
s.invalidateClaimCache(ctx, clientID)
}
return err
}
// PatchCustomClaimAdmin atomically creates or updates one claim for any client.
func (s *ClientStore) PatchCustomClaimAdmin(ctx context.Context, clientID string, def ClaimDefinition) error {
dest := normalizeDestinations(def.Destinations)
var sgArg sql.NullString
if def.ScopeGate != "" {
sgArg = sql.NullString{String: def.ScopeGate, Valid: true}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO client_claim_definitions (client_id, key, value_type, value, destinations, scope_gate, source_kind)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (client_id, key) DO UPDATE SET
value_type = EXCLUDED.value_type,
value = EXCLUDED.value,
destinations = EXCLUDED.destinations,
scope_gate = EXCLUDED.scope_gate,
source_kind = EXCLUDED.source_kind,
updated_at = NOW()`,
clientID, def.Key, def.ValueType, def.Value, dest, sgArg, normalizedSourceKind(def.SourceKind),
)
if err == nil {
s.invalidateClaimCache(ctx, clientID)
}
return err
}
func (s *ClientStore) replaceClaimRows(ctx context.Context, clientID string, defs []ClaimDefinition) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM client_claim_definitions WHERE client_id = $1`, clientID,
); err != nil {
return err
}
for _, d := range defs {
dest := normalizeDestinations(d.Destinations)
var sgArg sql.NullString
if d.ScopeGate != "" {
sgArg = sql.NullString{String: d.ScopeGate, Valid: true}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO client_claim_definitions (client_id, key, value_type, value, destinations, scope_gate, source_kind)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
clientID, d.Key, d.ValueType, d.Value, dest, sgArg, normalizedSourceKind(d.SourceKind),
); err != nil {
return err
}
}
return tx.Commit()
}
func normalizedSourceKind(sourceKind string) string {
if sourceKind == "" {
return "static"
}
return sourceKind
}
func (s *ClientStore) invalidateClaimCache(ctx context.Context, clientID string) {
if s.claimsCache == nil {
return
}
iter := s.claimsCache.Scan(ctx, 0, "claims:def:"+clientID+":*", 100).Iterator()
for iter.Next(ctx) {
_ = s.claimsCache.Del(ctx, iter.Val()).Err()
}
}
// normalizeDestinations sorts the comma-separated destinations to a canonical form.
func normalizeDestinations(d string) string {
if d == "" {
return "token"
}
parts := strings.Split(d, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
// scopeMatches returns true when scopeGate is empty (always inject) or scopeGate is a
// word-boundary match within grantedScope. Uses padding to avoid substring collisions
// (e.g. scope_gate="file" must not match grantedScope="profile").
func scopeMatches(scopeGate, grantedScope string) bool {
if scopeGate == "" {
return true
}
return strings.Contains(" "+grantedScope+" ", " "+scopeGate+" ")
}
// destMatches returns true when the row's destinations CSV includes the requested destination.
// "token" is the legacy alias meaning both access_token and id_token.
func destMatches(rowDests, destination string) bool {
for _, p := range strings.Split(rowDests, ",") {
if p == destination {
return true
}
if p == "token" && (destination == "access_token" || destination == "id_token") {
return true
}
}
return false
}
func generateClientSecret() string {
b := make([]byte, 30)
if _, err := rand.Read(b); err != nil {
log.Fatalf("crypto/rand unavailable: %v", err)
}
return "key_" + hex.EncodeToString(b)
}
package postgres
import (
"encoding/base64"
"errors"
"strings"
"time"
)
// PageCursor is an opaque continuation token for cursor-based pagination.
// It encodes the last record's (created_at, id) so the next query can use
// a WHERE clause instead of OFFSET.
type PageCursor struct {
CreatedAt time.Time
ID string
}
// EncodeCursor encodes a (created_at, id) pair as a URL-safe base64 string.
func EncodeCursor(t time.Time, id string) string {
raw := t.UTC().Format(time.RFC3339Nano) + "|" + id
return base64.URLEncoding.EncodeToString([]byte(raw))
}
// DecodeCursor parses a cursor encoded by EncodeCursor.
// Returns (nil, nil) for an empty string (first page).
func DecodeCursor(s string) (*PageCursor, error) {
if s == "" {
return nil, nil
}
raw, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return nil, errors.New("invalid cursor")
}
parts := strings.SplitN(string(raw), "|", 2)
if len(parts) != 2 {
return nil, errors.New("invalid cursor format")
}
t, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return nil, errors.New("invalid cursor timestamp")
}
return &PageCursor{CreatedAt: t, ID: parts[1]}, nil
}
package postgres
import (
"database/sql"
"log/slog"
_ "github.com/lib/pq"
)
// InitDB initializes the postgres database connection pool
func InitDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
if err = db.Ping(); err != nil {
return nil, err
}
slog.Info("Connected to PostgreSQL successfully")
return db, nil
}
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/iabhishekrajput/anekdote-auth/internal/idgen"
"github.com/iabhishekrajput/anekdote-auth/internal/models"
)
var ErrInvalidRole = errors.New("role 'owner' cannot be set via UpdateMemberRole; use ownership transfer")
var ErrOwnerCannotBeRemoved = errors.New("org owner cannot be removed; transfer ownership first")
var ErrTransferTargetNotMember = errors.New("transfer target is not an active member of this org")
type OrgStore struct {
db *sql.DB
}
func NewOrgStore(db *sql.DB) *OrgStore {
return &OrgStore{db: db}
}
func (s *OrgStore) BeginTx(ctx context.Context) (*sql.Tx, error) {
return s.db.BeginTx(ctx, nil)
}
// CreateOrgWithOwner inserts org + owner membership atomically within the provided tx.
func (s *OrgStore) CreateOrgWithOwner(ctx context.Context, tx *sql.Tx, slug, displayName string, ownerID string) (*models.Org, error) {
var org models.Org
org.ID = idgen.NewOrgID()
err := tx.QueryRowContext(ctx,
`INSERT INTO organizations (id, slug, display_name, owner_id)
VALUES ($1, $2, $3, $4)
RETURNING id, slug, display_name, owner_id, created_at, updated_at`,
org.ID, slug, displayName, ownerID,
).Scan(&org.ID, &org.Slug, &org.DisplayName, &org.OwnerID, &org.CreatedAt, &org.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("insert organization: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO org_memberships (org_id, user_id, role) VALUES ($1, $2, 'owner')`,
org.ID, ownerID,
)
if err != nil {
return nil, fmt.Errorf("insert owner membership: %w", err)
}
return &org, nil
}
func (s *OrgStore) GetOrgBySlug(ctx context.Context, slug string) (*models.Org, error) {
var org models.Org
err := s.db.QueryRowContext(ctx,
`SELECT id, slug, display_name, owner_id, created_at, updated_at
FROM organizations WHERE slug = $1 AND deleted_at IS NULL`,
slug,
).Scan(&org.ID, &org.Slug, &org.DisplayName, &org.OwnerID, &org.CreatedAt, &org.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &org, nil
}
func (s *OrgStore) GetOrgByID(ctx context.Context, id string) (*models.Org, error) {
var org models.Org
err := s.db.QueryRowContext(ctx,
`SELECT id, slug, display_name, owner_id, created_at, updated_at
FROM organizations WHERE id = $1 AND deleted_at IS NULL`,
id,
).Scan(&org.ID, &org.Slug, &org.DisplayName, &org.OwnerID, &org.CreatedAt, &org.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &org, nil
}
// GetMembership returns the user's active role or ("", nil) for no membership.
func (s *OrgStore) GetMembership(ctx context.Context, orgID, userID string) (string, error) {
var role string
err := s.db.QueryRowContext(ctx,
`SELECT role FROM org_memberships
WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
orgID, userID,
).Scan(&role)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
// ListOrgsForUser returns active memberships with org details.
func (s *OrgStore) ListOrgsForUser(ctx context.Context, userID string) ([]*models.OrgMembership, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT m.org_id, m.user_id, m.role, m.invited_by, m.joined_at,
o.slug, o.display_name, o.owner_id
FROM org_memberships m
JOIN organizations o ON o.id = m.org_id
WHERE m.user_id = $1 AND m.removed_at IS NULL AND o.deleted_at IS NULL
ORDER BY m.joined_at`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var memberships []*models.OrgMembership
for rows.Next() {
var m models.OrgMembership
var orgSlug, orgDisplayName, orgOwnerID string
if err := rows.Scan(&m.OrgID, &m.UserID, &m.Role, &m.InvitedBy, &m.JoinedAt,
&orgSlug, &orgDisplayName, &orgOwnerID); err != nil {
return nil, err
}
// Store org display name in UserEmail field (display-only; no extra struct)
// This is overloaded but avoids a separate type for the list view.
m.UserEmail = orgDisplayName + "|" + orgSlug
memberships = append(memberships, &m)
}
return memberships, rows.Err()
}
// ListOrgMemberships returns a typed list with full org info for /account/orgs list.
type OrgListItem struct {
Org models.Org
Role string
MemberCount int
}
func (s *OrgStore) ListOrgsForUserFull(ctx context.Context, userID string) ([]OrgListItem, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT o.id, o.slug, o.display_name, o.owner_id, o.created_at, o.updated_at,
m.role,
(SELECT COUNT(*) FROM org_memberships m2 WHERE m2.org_id = o.id AND m2.removed_at IS NULL) AS member_count
FROM org_memberships m
JOIN organizations o ON o.id = m.org_id
WHERE m.user_id = $1 AND m.removed_at IS NULL AND o.deleted_at IS NULL
ORDER BY m.joined_at`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OrgListItem
for rows.Next() {
var item OrgListItem
if err := rows.Scan(
&item.Org.ID, &item.Org.Slug, &item.Org.DisplayName, &item.Org.OwnerID,
&item.Org.CreatedAt, &item.Org.UpdatedAt,
&item.Role, &item.MemberCount,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// ListMembers returns active members for an org (removed_at IS NULL).
func (s *OrgStore) ListMembers(ctx context.Context, orgID string) ([]*models.OrgMembership, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT m.org_id, m.user_id, m.role, m.invited_by, m.joined_at, u.email
FROM org_memberships m
JOIN users u ON u.id = m.user_id
WHERE m.org_id = $1 AND m.removed_at IS NULL
ORDER BY m.joined_at`,
orgID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var members []*models.OrgMembership
for rows.Next() {
var m models.OrgMembership
if err := rows.Scan(&m.OrgID, &m.UserID, &m.Role, &m.InvitedBy, &m.JoinedAt, &m.UserEmail); err != nil {
return nil, err
}
members = append(members, &m)
}
return members, rows.Err()
}
// AddMember upserts — clears removed_at AND updates role on conflict.
func (s *OrgStore) AddMember(ctx context.Context, orgID, userID string, role string, invitedBy *string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO org_memberships (org_id, user_id, role, invited_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (org_id, user_id) DO UPDATE
SET removed_at = NULL,
role = EXCLUDED.role,
joined_at = NOW(),
invited_by = EXCLUDED.invited_by`,
orgID, userID, role, invitedBy,
)
return err
}
// RemoveMember soft-deletes. Rejects if user is the org owner.
func (s *OrgStore) RemoveMember(ctx context.Context, orgID, userID string) error {
// Check ownership using organizations.owner_id (source of truth)
var ownerID string
err := s.db.QueryRowContext(ctx,
`SELECT owner_id FROM organizations WHERE id = $1`, orgID,
).Scan(&ownerID)
if err != nil {
return fmt.Errorf("lookup org owner: %w", err)
}
if ownerID == userID {
return ErrOwnerCannotBeRemoved
}
_, err = s.db.ExecContext(ctx,
`UPDATE org_memberships SET removed_at = NOW()
WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
orgID, userID,
)
return err
}
// UpdateMemberRole changes role. Rejects 'owner' — use ownership transfer path.
func (s *OrgStore) UpdateMemberRole(ctx context.Context, orgID, userID string, role string) error {
if role == "owner" {
return ErrInvalidRole
}
_, err := s.db.ExecContext(ctx,
`UPDATE org_memberships SET role = $3
WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
orgID, userID, role,
)
return err
}
// TransferOwnershipAndLeave atomically transfers org ownership from fromOwnerID to toUserID
// and removes fromOwnerID's membership. Two sources of truth are updated in one TX:
// organizations.owner_id and org_memberships.role — they must always stay in sync.
func (s *OrgStore) TransferOwnershipAndLeave(ctx context.Context, orgID, fromOwnerID, toUserID string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Lock target membership row and verify target is an active member.
var targetRole string
err = tx.QueryRowContext(ctx,
`SELECT role FROM org_memberships
WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL
FOR UPDATE`,
orgID, toUserID,
).Scan(&targetRole)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrTransferTargetNotMember
}
return fmt.Errorf("lock target membership: %w", err)
}
// Transfer ownership on organizations table.
res, err := tx.ExecContext(ctx,
`UPDATE organizations SET owner_id = $1 WHERE id = $2 AND owner_id = $3`,
toUserID, orgID, fromOwnerID,
)
if err != nil {
return fmt.Errorf("update organizations owner_id: %w", err)
}
if n, _ := res.RowsAffected(); n != 1 {
return fmt.Errorf("ownership already changed or org not found (race condition)")
}
// Update new owner's membership role.
res, err = tx.ExecContext(ctx,
`UPDATE org_memberships SET role = 'owner'
WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
orgID, toUserID,
)
if err != nil {
return fmt.Errorf("update new owner membership role: %w", err)
}
if n, _ := res.RowsAffected(); n != 1 {
return fmt.Errorf("new owner membership update affected unexpected rows (race condition)")
}
// Soft-delete former owner's membership.
_, err = tx.ExecContext(ctx,
`UPDATE org_memberships SET removed_at = NOW()
WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
orgID, fromOwnerID,
)
if err != nil {
return fmt.Errorf("remove former owner membership: %w", err)
}
return tx.Commit()
}
// CountClients returns the number of OAuth2 clients registered to an org.
func (s *OrgStore) CountClients(ctx context.Context, orgID string) (int, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM oauth2_clients WHERE org_id = $1`, orgID,
).Scan(&count)
return count, err
}
// AdminOrgItem is used by the admin panel for the org list view.
type AdminOrgItem struct {
Org models.Org
MemberCount int
ClientCount int
}
// ListAllCursor returns orgs with member and client counts using cursor-based pagination.
// Returns items, next-page cursor (empty = last page), and total count.
func (s *OrgStore) ListAllCursor(ctx context.Context, limit int, cursor *PageCursor) ([]AdminOrgItem, string, int, error) {
total, err := s.CountAll(ctx)
if err != nil {
return nil, "", 0, err
}
const selectCols = `SELECT o.id, o.slug, o.display_name, o.owner_id, o.created_at, o.updated_at,
(SELECT COUNT(*) FROM org_memberships m WHERE m.org_id = o.id AND m.removed_at IS NULL) AS member_count,
(SELECT COUNT(*) FROM oauth2_clients c WHERE c.org_id = o.id) AS client_count
FROM organizations o`
var rows *sql.Rows
if cursor == nil {
rows, err = s.db.QueryContext(ctx,
selectCols+` WHERE o.deleted_at IS NULL ORDER BY o.created_at DESC, o.id DESC LIMIT $1`,
limit+1,
)
} else {
rows, err = s.db.QueryContext(ctx,
selectCols+` WHERE o.deleted_at IS NULL AND (o.created_at < $1 OR (o.created_at = $1 AND o.id < $2))
ORDER BY o.created_at DESC, o.id DESC LIMIT $3`,
cursor.CreatedAt, cursor.ID, limit+1,
)
}
if err != nil {
return nil, "", total, err
}
defer rows.Close()
var items []AdminOrgItem
for rows.Next() {
var item AdminOrgItem
if err := rows.Scan(
&item.Org.ID, &item.Org.Slug, &item.Org.DisplayName, &item.Org.OwnerID,
&item.Org.CreatedAt, &item.Org.UpdatedAt,
&item.MemberCount, &item.ClientCount,
); err != nil {
return nil, "", total, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, "", total, err
}
nextCursor := ""
if len(items) > limit {
last := items[limit-1]
nextCursor = EncodeCursor(last.Org.CreatedAt, last.Org.ID)
items = items[:limit]
}
return items, nextCursor, total, nil
}
// CountAll returns the total number of non-deleted orgs.
func (s *OrgStore) CountAll(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM organizations WHERE deleted_at IS NULL`).Scan(&count)
return count, err
}
var ErrOrgHasClients = errors.New("org has OAuth2 clients; delete them before deleting the org")
// DeleteOrg soft-deletes an org: removes OAuth2 clients, soft-removes memberships,
// and sets deleted_at. oauth2_clients are hard-deleted so client_id becomes available again.
func (s *OrgStore) DeleteOrg(ctx context.Context, id string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Lock org row.
var currentDeletedAt *time.Time
var orgSlug string
if err := tx.QueryRowContext(ctx,
`SELECT deleted_at, slug FROM organizations WHERE id = $1 FOR UPDATE`, id,
).Scan(¤tDeletedAt, &orgSlug); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("org not found")
}
return err
}
if currentDeletedAt != nil {
return fmt.Errorf("org not found")
}
// Hard-delete OAuth2 clients owned by this org.
if _, err := tx.ExecContext(ctx,
`DELETE FROM oauth2_clients WHERE org_id = $1`, id,
); err != nil {
return fmt.Errorf("delete org clients: %w", err)
}
// Soft-remove all active memberships.
if _, err := tx.ExecContext(ctx,
`UPDATE org_memberships SET removed_at = NOW()
WHERE org_id = $1 AND removed_at IS NULL`, id,
); err != nil {
return fmt.Errorf("remove org memberships: %w", err)
}
// Soft-delete the org itself.
if _, err := tx.ExecContext(ctx,
`UPDATE organizations SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1`, id,
); err != nil {
return fmt.Errorf("soft-delete org: %w", err)
}
return tx.Commit()
}
package postgres
import (
"context"
"database/sql"
"errors"
"time"
"github.com/iabhishekrajput/anekdote-auth/internal/idgen"
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/lib/pq"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrLastAdmin = errors.New("cannot remove the last admin")
ErrEmailTaken = errors.New("email already registered")
ErrUsernameTaken = errors.New("username already taken")
)
var validAdminRoles = map[string]bool{
"superadmin": true,
"readonly": true,
"org_admin": true,
}
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
func (s *UserStore) GetByEmail(email string) (*models.User, error) {
u := &models.User{}
var adminRole, username sql.NullString
err := s.db.QueryRow(`
SELECT id, email, name, username, password_hash, is_verified, is_admin, admin_role, password_changed, disabled_at, deleted_at, created_at, updated_at
FROM users WHERE email = $1 AND deleted_at IS NULL`, email).
Scan(&u.ID, &u.Email, &u.Name, &username, &u.PasswordHash, &u.IsVerified, &u.IsAdmin, &adminRole, &u.PasswordChanged, &u.DisabledAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
u.AdminRole = adminRole.String
u.Username = username.String
return u, nil
}
func (s *UserStore) GetByID(id string) (*models.User, error) {
u := &models.User{}
var adminRole, username sql.NullString
err := s.db.QueryRow(`
SELECT id, email, name, username, password_hash, is_verified, is_admin, admin_role, password_changed, disabled_at, deleted_at, created_at, updated_at
FROM users WHERE id = $1 AND deleted_at IS NULL`, id).
Scan(&u.ID, &u.Email, &u.Name, &username, &u.PasswordHash, &u.IsVerified, &u.IsAdmin, &adminRole, &u.PasswordChanged, &u.DisabledAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
u.AdminRole = adminRole.String
u.Username = username.String
return u, nil
}
// SetAdmin sets the is_admin flag for a user. Demoting the last admin returns ErrLastAdmin.
// Uses SELECT FOR UPDATE inside a transaction to prevent TOCTOU races.
func (s *UserStore) SetAdmin(ctx context.Context, id string, admin bool) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if !admin {
var count int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE is_admin = true FOR UPDATE`).Scan(&count); err != nil {
return err
}
if count <= 1 {
return ErrLastAdmin
}
}
var adminRole interface{}
if admin {
adminRole = "superadmin"
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET is_admin = $1, admin_role = $2, updated_at = NOW() WHERE id = $3`, admin, adminRole, id); err != nil {
return err
}
return tx.Commit()
}
// SetAdminRole updates the admin_role column. Only valid roles are accepted.
func (s *UserStore) SetAdminRole(ctx context.Context, id string, role string) error {
if !validAdminRoles[role] {
return errors.New("invalid admin role: must be superadmin, readonly, or org_admin")
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET admin_role = $1, updated_at = NOW() WHERE id = $2`, role, id)
return err
}
// CountAdmins returns the number of admin users.
func (s *UserStore) CountAdmins(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE is_admin = true`).Scan(&count)
return count, err
}
// UserListItem is used by the admin panel for list views.
type UserListItem struct {
ID string
Email string
Name string
IsVerified bool
DisabledAt *time.Time
CreatedAt time.Time
}
// ListAllCursor returns up to limit users using cursor-based pagination.
// cursor may be nil for the first page. Returns the items, a next-page cursor
// (empty string if no more pages), and the total count.
func (s *UserStore) ListAllCursor(ctx context.Context, limit int, cursor *PageCursor) ([]*UserListItem, string, int, error) {
total, err := s.CountAll(ctx)
if err != nil {
return nil, "", 0, err
}
var rows *sql.Rows
if cursor == nil {
rows, err = s.db.QueryContext(ctx,
`SELECT id, email, name, is_verified, disabled_at, created_at
FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT $1`,
limit+1,
)
} else {
rows, err = s.db.QueryContext(ctx,
`SELECT id, email, name, is_verified, disabled_at, created_at
FROM users
WHERE deleted_at IS NULL AND (created_at < $1 OR (created_at = $1 AND id < $2))
ORDER BY created_at DESC, id DESC LIMIT $3`,
cursor.CreatedAt, cursor.ID, limit+1,
)
}
if err != nil {
return nil, "", total, err
}
defer rows.Close()
var users []*UserListItem
for rows.Next() {
u := &UserListItem{}
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsVerified, &u.DisabledAt, &u.CreatedAt); err != nil {
return nil, "", total, err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, "", total, err
}
nextCursor := ""
if len(users) > limit {
last := users[limit-1]
nextCursor = EncodeCursor(last.CreatedAt, last.ID)
users = users[:limit]
}
return users, nextCursor, total, nil
}
// CountAll returns the total number of non-deleted users.
func (s *UserStore) CountAll(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&count)
return count, err
}
var ErrUserOwnsOrg = errors.New("user owns one or more organizations; transfer ownership before deleting")
// DeleteUser soft-deletes a user by anonymizing their email/name and setting deleted_at.
// Fails if the user still owns any non-deleted organizations.
// Uses SELECT FOR UPDATE to prevent concurrent double-delete races.
func (s *UserStore) DeleteUser(ctx context.Context, id string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Lock the user row.
var currentDeletedAt *time.Time
if err := tx.QueryRowContext(ctx,
`SELECT deleted_at FROM users WHERE id = $1 FOR UPDATE`, id,
).Scan(¤tDeletedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUserNotFound
}
return err
}
if currentDeletedAt != nil {
return ErrUserNotFound
}
// Reject if user still owns orgs.
var ownedOrgs int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM organizations WHERE owner_id = $1 AND deleted_at IS NULL`, id,
).Scan(&ownedOrgs); err != nil {
return err
}
if ownedOrgs > 0 {
return ErrUserOwnsOrg
}
// Anonymize and soft-delete.
_, err = tx.ExecContext(ctx,
`UPDATE users SET
deleted_at = NOW(),
email = 'deleted-' || id || '@deleted.invalid',
name = '[deleted]',
password_hash = '',
updated_at = NOW()
WHERE id = $1`, id,
)
if err != nil {
return err
}
return tx.Commit()
}
// SetDisabled sets or clears disabled_at for a user.
func (s *UserStore) SetDisabled(ctx context.Context, id string, disabled bool) error {
var err error
if disabled {
_, err = s.db.ExecContext(ctx,
`UPDATE users SET disabled_at = NOW(), updated_at = NOW() WHERE id = $1`, id)
} else {
_, err = s.db.ExecContext(ctx,
`UPDATE users SET disabled_at = NULL, updated_at = NOW() WHERE id = $1`, id)
}
return err
}
func (s *UserStore) Create(email, name, username, passwordHash string) (*models.User, error) {
u := &models.User{}
id := idgen.NewUserID()
var usernameArg sql.NullString
if username != "" {
usernameArg = sql.NullString{String: username, Valid: true}
}
err := s.db.QueryRow(`
INSERT INTO users (id, email, name, username, password_hash, password_changed)
VALUES ($1, $2, $3, $4, $5, TRUE)
RETURNING id, email, name, username, password_hash, is_verified, created_at, updated_at`,
id, email, name, usernameArg, passwordHash).
Scan(&u.ID, &u.Email, &u.Name, &usernameArg, &u.PasswordHash, &u.IsVerified, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
switch pqErr.Constraint {
case "uq_idx_users_email":
return nil, ErrEmailTaken
case "uq_idx_users_username":
return nil, ErrUsernameTaken
}
}
return nil, err
}
u.Username = usernameArg.String
u.PasswordChanged = true
return u, nil
}
func (s *UserStore) UpdateName(id string, newName string) error {
_, err := s.db.Exec(`UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, newName, id)
return err
}
func (s *UserStore) UpdateUsername(ctx context.Context, id string, username string) error {
var usernameArg sql.NullString
if username != "" {
usernameArg = sql.NullString{String: username, Valid: true}
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET username = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
usernameArg, id,
)
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" && pqErr.Constraint == "uq_idx_users_username" {
return ErrUsernameTaken
}
return err
}
func (s *UserStore) UpdatePassword(id string, newHash string) error {
_, err := s.db.Exec(`UPDATE users SET password_hash = $1, password_changed = TRUE, updated_at = NOW() WHERE id = $2`, newHash, id)
return err
}
func (s *UserStore) UpdateVerified(id string) error {
_, err := s.db.Exec(`UPDATE users SET is_verified = TRUE, updated_at = NOW() WHERE id = $1`, id)
return err
}
// ListOrgAdmins returns the emails of active owners and admins in the given org.
func (s *UserStore) ListAllUsernames(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT username FROM users WHERE deleted_at IS NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
var usernames []string
for rows.Next() {
var u string
if err := rows.Scan(&u); err != nil {
return nil, err
}
usernames = append(usernames, u)
}
return usernames, rows.Err()
}
func (s *UserStore) IsUsernameTaken(ctx context.Context, username string) (bool, error) {
var exists bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 AND deleted_at IS NULL)`,
username,
).Scan(&exists)
return exists, err
}
func (s *UserStore) ListOrgAdmins(ctx context.Context, orgID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT u.email FROM users u
JOIN org_memberships m ON m.user_id = u.id
WHERE m.org_id = $1 AND m.role IN ('owner', 'admin')
AND m.removed_at IS NULL
AND u.disabled_at IS NULL AND u.deleted_at IS NULL`,
orgID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var emails []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
emails = append(emails, email)
}
return emails, rows.Err()
}
package redis
import (
"context"
"strings"
"github.com/go-redis/redis/v8"
"hash/fnv"
)
const (
bloomRedisKey = "bloom:usernames"
// bloomM: bit-array size. Sized for 100k users at ~1% false-positive rate.
bloomM = 958506
// bloomK: number of independent hash positions, derived from m/n * ln2.
bloomK = 7
)
// UsernameBloom is a Redis-backed bit-array bloom filter for username lookups.
// MightExist returning false guarantees the username is not taken.
// MightExist returning true means "probably taken" — always confirm with the DB.
type UsernameBloom struct {
client *redis.Client
}
// NewUsernameBloom creates a bloom filter backed by the given Redis client.
func NewUsernameBloom(client *redis.Client) *UsernameBloom {
return &UsernameBloom{client: client}
}
// positions computes k bit positions for s using enhanced double hashing:
// h_i(x) = (h1(x) + i * h2(x)) % m.
// FNV-64a (h1) and FNV-64 (h2) give independent hash families.
func (b *UsernameBloom) positions(s string) [bloomK]uint64 {
lower := strings.ToLower(s)
h1 := fnv.New64a()
h1.Write([]byte(lower))
a := h1.Sum64()
h2 := fnv.New64()
h2.Write([]byte(lower))
c := h2.Sum64()
var pos [bloomK]uint64
for i := uint64(0); i < bloomK; i++ {
pos[i] = (a + i*c) % bloomM
}
return pos
}
// Add sets the k bits for username in the Redis bit array.
// Idempotent: safe to call for usernames already in the filter.
func (b *UsernameBloom) Add(ctx context.Context, username string) error {
pipe := b.client.Pipeline()
for _, p := range b.positions(username) {
pipe.SetBit(ctx, bloomRedisKey, int64(p), 1)
}
_, err := pipe.Exec(ctx)
return err
}
// MightExist returns (false, nil) when the username is definitely not taken.
// Returns (true, nil) when the username is possibly taken (check the DB).
// Returns (true, err) on Redis failure — callers must fall back to the DB.
func (b *UsernameBloom) MightExist(ctx context.Context, username string) (bool, error) {
pipe := b.client.Pipeline()
cmds := make([]*redis.IntCmd, bloomK)
for i, p := range b.positions(username) {
cmds[i] = pipe.GetBit(ctx, bloomRedisKey, int64(p))
}
if _, err := pipe.Exec(ctx); err != nil {
return true, err
}
for _, cmd := range cmds {
if cmd.Val() == 0 {
return false, nil
}
}
return true, nil
}
// LoadAll populates the filter from a list of existing usernames.
// Uses a single pipelined batch; safe to call at startup.
func (b *UsernameBloom) LoadAll(ctx context.Context, usernames []string) error {
if len(usernames) == 0 {
return nil
}
pipe := b.client.Pipeline()
for _, u := range usernames {
for _, p := range b.positions(u) {
pipe.SetBit(ctx, bloomRedisKey, int64(p), 1)
}
}
_, err := pipe.Exec(ctx)
return err
}
package redis
import (
"context"
"log/slog"
"github.com/go-redis/redis/v8"
)
// InitRedis initializes the go-redis client
func InitRedis(dsn string) (*redis.Client, error) {
opt, err := redis.ParseURL(dsn)
if err != nil {
return nil, err
}
client := redis.NewClient(opt)
// Ping to verify connection
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
slog.Info("Connected to Redis successfully")
return client, nil
}
package redis
import (
"context"
"time"
goredis "github.com/go-redis/redis/v8"
)
// NonceStore persists one-time OIDC nonce bindings by authorization code.
type NonceStore struct {
client *goredis.Client
}
func NewNonceStore(client *goredis.Client) *NonceStore {
return &NonceStore{client: client}
}
// StoreNonce persists the OIDC nonce tied to an authorization code.
func (s *NonceStore) StoreNonce(ctx context.Context, code, nonce string, ttl time.Duration) error {
return s.client.Set(ctx, "oidc_nonce:"+code, nonce, ttl).Err()
}
// ConsumeNonce retrieves and atomically deletes the nonce for a code.
func (s *NonceStore) ConsumeNonce(ctx context.Context, code string) (string, error) {
nonce, err := s.client.GetDel(ctx, "oidc_nonce:"+code).Result()
if err == goredis.Nil {
return "", nil
}
return nonce, err
}
package redisutil
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
)
// HashForStorage returns the lowercase hex SHA-256 of val.
// Store this as the Redis key or value so the raw secret never appears in Redis.
// On lookup, hash the submitted value the same way.
func HashForStorage(val string) string {
sum := sha256.Sum256([]byte(val))
return hex.EncodeToString(sum[:])
}
// Encrypt encrypts plaintext with AES-256-GCM using key (must be exactly 32 bytes).
// Returns standard base64-encoded nonce||ciphertext||tag. Each call produces a
// unique ciphertext due to a random nonce.
func Encrypt(key []byte, plaintext string) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ct), nil
}
// Decrypt reverses Encrypt. Returns an error if the ciphertext is malformed,
// the key is wrong, or the GCM tag check fails.
func Decrypt(key []byte, ciphertext string) (string, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
pt, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
if err != nil {
return "", err
}
return string(pt), nil
}
package redis
import (
"context"
"time"
"github.com/go-redis/redis/v8"
)
type RevocationStore struct {
client *redis.Client
}
func NewRevocationStore(client *redis.Client) *RevocationStore {
return &RevocationStore{client: client}
}
// RevokeJTI adds a token's JTI to the blocklist in Redis for the remainder of its TTL.
func (s *RevocationStore) RevokeJTI(ctx context.Context, jti string, duration time.Duration) error {
key := "revoked_jti:" + jti
return s.client.Set(ctx, key, "revoked", duration).Err()
}
// IsRevoked checks if a JTI is currently in the blocklist.
func (s *RevocationStore) IsRevoked(ctx context.Context, jti string) (bool, error) {
key := "revoked_jti:" + jti
val, err := s.client.Get(ctx, key).Result()
if err == redis.Nil {
return false, nil // Not revoked
} else if err != nil {
return false, err // Redis error
}
return val == "revoked", nil
}
// StoreNonce persists the OIDC nonce tied to an authorization code.
// TTL should match the code's own expiry (typically 10 minutes).
func (s *RevocationStore) StoreNonce(ctx context.Context, code, nonce string, ttl time.Duration) error {
return s.client.Set(ctx, "oidc_nonce:"+code, nonce, ttl).Err()
}
// ConsumeNonce retrieves and atomically deletes the nonce for a code (one-time read).
// Returns ("", nil) when no nonce exists for the code.
func (s *RevocationStore) ConsumeNonce(ctx context.Context, code string) (string, error) {
nonce, err := s.client.GetDel(ctx, "oidc_nonce:"+code).Result()
if err == redis.Nil {
return "", nil
}
return nonce, err
}
package redis
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/go-redis/redis/v8"
"github.com/google/uuid"
"github.com/iabhishekrajput/anekdote-auth/internal/store/redis/redisutil"
)
const (
sessionTTL = 24 * time.Hour
otpTTL = 15 * time.Minute
pendingInviteTTL = 30 * time.Minute
)
var ErrSessionNotFound = errors.New("session not found")
type SessionStore struct {
client *redis.Client
}
func NewSessionStore(client *redis.Client) *SessionStore {
return &SessionStore{client: client}
}
// Create generates a new session ID for a given userID and stores it in Redis
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
sessionID := uuid.New().String()
key := "session:" + sessionID
err := s.client.Set(ctx, key, userID, sessionTTL).Err()
if err != nil {
return "", err
}
return sessionID, nil
}
// Get retrieves the userID associated with a session ID
func (s *SessionStore) Get(ctx context.Context, sessionID string) (string, error) {
key := "session:" + sessionID
val, err := s.client.Get(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return "", ErrSessionNotFound
}
return "", err
}
return val, nil
}
// Delete removes a session ID from Redis (Logout)
func (s *SessionStore) Delete(ctx context.Context, sessionID string) error {
key := "session:" + sessionID
return s.client.Del(ctx, key).Err()
}
// GetUserFromSession is a helper to extract the user ID from the request cookie
func (s *SessionStore) GetUserFromSession(r *http.Request) (string, error) {
cookie, err := r.Cookie("auth_session")
if err != nil {
return "", err
}
return s.Get(context.Background(), cookie.Value)
}
// CreateOTP stores a SHA-256 hash of the 6-digit OTP in Redis.
// The raw OTP is sent to the user via email; only the hash lives in Redis
// so a Redis read compromise cannot be used to bypass email verification.
func (s *SessionStore) CreateOTP(ctx context.Context, userID string, otp string) error {
key := "otp:" + userID
return s.client.Set(ctx, key, redisutil.HashForStorage(otp), otpTTL).Err()
}
// VerifyOTP checks if the SHA-256 hash of submittedOTP matches what is stored in Redis.
// Consumes the OTP on success to prevent reuse.
func (s *SessionStore) VerifyOTP(ctx context.Context, userID string, submittedOTP string) (bool, error) {
key := "otp:" + userID
stored, err := s.client.Get(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return false, nil // code doesn't exist or expired
}
return false, err
}
if stored == redisutil.HashForStorage(submittedOTP) {
s.client.Del(ctx, key)
return true, nil
}
return false, nil
}
// IncrementFailedLogin tracks failed login attempts for an email and returns the new count
func (s *SessionStore) IncrementFailedLogin(ctx context.Context, email string) (int, error) {
key := "failed_login:" + email
count, err := s.client.Incr(ctx, key).Result()
if err != nil {
return 0, err
}
if count == 1 {
s.client.Expire(ctx, key, 15*time.Minute)
}
return int(count), nil
}
// ResetFailedLogin clears the failed login attempts
func (s *SessionStore) ResetFailedLogin(ctx context.Context, email string) error {
key := "failed_login:" + email
return s.client.Del(ctx, key).Err()
}
// GetFailedLogin returns the current failed login count
func (s *SessionStore) GetFailedLogin(ctx context.Context, email string) (int, error) {
key := "failed_login:" + email
val, err := s.client.Get(ctx, key).Int()
if err != nil {
if errors.Is(err, redis.Nil) {
return 0, nil
}
return 0, err
}
return val, nil
}
// CreateResetToken generates a short-lived password-reset token.
// The key stored in Redis is sha256(token) so that a SCAN of reset_token:* does not
// expose usable reset links — only the raw token, sent in the email, can look up the entry.
func (s *SessionStore) CreateResetToken(ctx context.Context, userID string) (string, error) {
resetToken := uuid.New().String()
key := "reset_token:" + redisutil.HashForStorage(resetToken)
err := s.client.Set(ctx, key, userID, 15*time.Minute).Err()
if err != nil {
return "", err
}
return resetToken, nil
}
// GetUserByResetToken retrieves the user ID from a valid reset token.
// The token parameter is the raw value from the email link; it is hashed before lookup.
func (s *SessionStore) GetUserByResetToken(ctx context.Context, resetToken string) (string, error) {
key := "reset_token:" + redisutil.HashForStorage(resetToken)
val, err := s.client.Get(ctx, key).Result()
if err != nil {
return "", err // redis.Nil means expired or not found
}
return val, nil
}
// DeleteResetToken invalidates a reset token after use.
func (s *SessionStore) DeleteResetToken(ctx context.Context, resetToken string) error {
key := "reset_token:" + redisutil.HashForStorage(resetToken)
return s.client.Del(ctx, key).Err()
}
// SetPendingInvite stores an invite token for the user awaiting OTP verification.
// Called by RegisterFunc when ?invite=<T> is present in the request.
func (s *SessionStore) SetPendingInvite(ctx context.Context, userID string, inviteToken string) error {
key := "invite:pending:" + userID
return s.client.Set(ctx, key, inviteToken, pendingInviteTTL).Err()
}
// DeleteAllForUser scans all session keys and deletes any belonging to userID.
// Used by the admin panel when disabling an account to immediately revoke active sessions.
func (s *SessionStore) DeleteAllForUser(ctx context.Context, userID string) error {
var cursor uint64
for {
keys, next, err := s.client.Scan(ctx, cursor, "session:*", 100).Result()
if err != nil {
return err
}
for _, key := range keys {
val, err := s.client.Get(ctx, key).Result()
if err != nil {
continue
}
if val == userID {
if delErr := s.client.Del(ctx, key).Err(); delErr != nil {
slog.Warn("session: failed to delete session for user", "user_id", userID, "key", key, "err", delErr)
}
}
}
cursor = next
if cursor == 0 {
break
}
}
return nil
}
// GetAndDeletePendingInvite reads and atomically deletes the pending invite token.
// Returns ("", nil) if no pending invite exists — not an error.
func (s *SessionStore) GetAndDeletePendingInvite(ctx context.Context, userID string) (string, error) {
key := "invite:pending:" + userID
val, err := s.client.GetDel(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return "", nil
}
return "", err
}
return val, nil
}
package redis
import (
oredis "github.com/go-oauth2/redis/v4"
"github.com/go-redis/redis/v8"
)
func NewTokenStore(client *redis.Client) *oredis.TokenStore {
return oredis.NewRedisStore(client.Options(), "token:")
}
// Package web holds small HTTP helpers shared across handlers and middleware:
// open-redirect-safe path validation and session-cookie management.
package web
import (
"net/http"
"net/url"
"strings"
)
// SessionCookieName is the browser cookie that carries the session ID.
const SessionCookieName = "auth_session"
// IsSafeLocalRedirect reports whether next is a safe same-origin path to
// redirect to. It must begin with a single "/" and must not be a
// protocol-relative ("//host") or backslash ("/\host") form — browsers
// normalize both to an absolute off-site URL, which would be an open redirect.
func IsSafeLocalRedirect(next string) bool {
if !strings.HasPrefix(next, "/") {
return false
}
// Reject "//host" and "/\host": the second byte must not be a slash or
// backslash. (A lone "/" is fine — it redirects to the site root.)
if len(next) > 1 && (next[1] == '/' || next[1] == '\\') {
return false
}
return true
}
// SafeLocalRedirect returns a guaranteed same-origin redirect target. Any
// scheme/host on candidate is stripped so a full URL (e.g. a Referer header)
// collapses to its path before validation; if the result is not a safe local
// path, fallback is returned instead.
func SafeLocalRedirect(candidate, fallback string) string {
if u, err := url.Parse(candidate); err == nil && u.Path != "" {
// Honor only the path (+query); discard any scheme/host so an
// attacker-controlled Referer cannot bounce the user off-site.
candidate = u.Path
if u.RawQuery != "" {
candidate += "?" + u.RawQuery
}
}
if IsSafeLocalRedirect(candidate) {
return candidate
}
return fallback
}
// secureRequest reports whether the request arrived over HTTPS, honoring a
// TLS-terminating proxy via X-Forwarded-Proto.
func secureRequest(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// ClearSessionCookie expires the session cookie using the same security
// attributes it is set with, so browsers reliably drop it. Using the canonical
// SessionCookieName ensures the live cookie is actually cleared (a prior bug
// cleared a non-existent "session_id" cookie, leaving the session cookie set).
func ClearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: secureRequest(r),
SameSite: http.SameSiteLaxMode,
})
}
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
)
func AccountPage(csrfToken string, user *models.User, isAdmin bool, orgs []postgres.OrgListItem, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout("Account Settings - anekdote", "/account", csrfToken, isAdmin, AccountPageBody(csrfToken, user, orgs, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AccountPageBody(csrfToken string, user *models.User, orgs []postgres.OrgListItem, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full max-w-2xl mx-auto\"><div class=\"mb-8\"><h1 class=\"text-xl font-semibold tracking-tight\">Account settings</h1><p class=\"mt-0.5 text-sm text-zinc-400\">Manage your profile and security</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !user.PasswordChanged {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mb-4 rounded-lg border border-amber-800 bg-amber-500/10 px-4 py-3 text-sm text-amber-300 flex items-center justify-between gap-4\"><span>You are using the default password. Change it now to secure your account.</span> <button type=\"button\" data-dialog-show=\"change-password-dialog\" class=\"shrink-0 rounded-md border border-amber-700 px-3 py-1 text-xs text-amber-200 hover:bg-amber-900/40 transition-colors duration-150\">Change password →</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!-- Profile section --><section class=\"pb-8 border-b border-zinc-800/60\"><div class=\"flex items-center justify-between mb-3\"><h3 class=\"text-base font-semibold\">Profile</h3><button type=\"button\" data-dialog-show=\"edit-profile-dialog\" class=\"h-8 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50 hover:border-zinc-700 transition-colors duration-150 min-w-[150px]\">Edit</button></div><div class=\"flex items-center gap-3\"><div class=\"h-10 w-10 rounded-full bg-zinc-800 flex items-center justify-center shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len([]rune(user.Name)) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-sm font-semibold text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(string([]rune(user.Name)[:1]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 45, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if len([]rune(user.Email)) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"text-sm font-semibold text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(string([]rune(user.Email)[:1]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 47, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"text-sm font-semibold text-zinc-200\">?</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Name != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-sm font-medium text-zinc-50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 54, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"text-sm text-zinc-500 italic\">No name set</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 58, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Username != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"text-xs text-zinc-600\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("@" + user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 60, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div></section><!-- Organizations section --><section class=\"mt-8 pb-8 border-b border-zinc-800/60\"><div class=\"flex items-center justify-between mb-3\"><h3 class=\"text-base font-semibold\">Organizations</h3><a href=\"/account/orgs\" class=\"h-8 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50 hover:border-zinc-700 transition-colors duration-150 inline-flex items-center\">Manage →</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(orgs) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<p class=\"text-sm text-zinc-500\">You don't belong to any organizations.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<ul class=\"space-y-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range orgs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<li class=\"flex items-center justify-between gap-3 rounded-md border border-zinc-800/60 bg-zinc-900/40 px-3 py-2\"><div><p class=\"text-sm font-medium text-zinc-50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 82, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p><p class=\"text-xs text-zinc-500 capitalize\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 83, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Role == "owner" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/account/orgs/" + item.Org.Slug + "#transfer-ownership"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 87, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"shrink-0 text-xs text-indigo-400 hover:text-indigo-300 underline underline-offset-2 transition-colors duration-150\">Transfer ownership to leave</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<button type=\"button\" data-dialog-show=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("leave-" + item.Org.Slug + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 93, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"shrink-0 h-7 rounded-md border border-zinc-800 px-2.5 text-xs text-zinc-400 hover:text-red-400 hover:border-red-900 transition-colors duration-150\">Leave</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</section><!-- Security section --><section class=\"mt-8 pb-8 border-b border-zinc-800/60\"><div class=\"flex items-center justify-between mb-3\"><h3 class=\"text-base font-semibold\">Security</h3><button type=\"button\" data-dialog-show=\"change-password-dialog\" class=\"h-8 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50 hover:border-zinc-700 transition-colors duration-150 min-w-[150px]\">Change password</button></div><div class=\"flex items-center gap-3\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-zinc-500 shrink-0\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"></rect> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"></path></svg><div><p class=\"text-sm font-medium text-zinc-50\">Password</p><span class=\"text-sm font-mono text-zinc-500 tracking-widest\">••••••••</span></div></div></section><!-- Danger zone section --><section class=\"mt-8\"><h3 class=\"text-base font-semibold text-red-400 mb-3\">Danger zone</h3><div class=\"rounded-lg border border-red-900/40 bg-red-950/20 px-4 py-3 flex items-center justify-between gap-4\"><div><p class=\"text-sm font-medium text-zinc-200\">Delete account</p><p class=\"text-xs text-zinc-500 mt-0.5\">Permanently anonymizes your data. Cannot be undone.</p></div><button type=\"button\" data-dialog-show=\"delete-account-dialog\" class=\"shrink-0 h-8 rounded-md border border-red-800 px-3 text-sm text-red-400 hover:bg-red-500/10 transition-colors duration-150\">Delete account</button></div></section></div><!-- Edit profile modal --><div id=\"edit-profile-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-4 text-base font-semibold\">Edit profile</h2><form method=\"POST\" action=\"/account/profile\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 147, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("Full name", "name", "", TextInput("name", "name", "text", "Your name", user.Name, false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"space-y-1.5\"><label for=\"username\" class=\"block text-sm font-medium text-zinc-300\">Username</label><div class=\"flex items-center rounded-md border border-zinc-800 bg-transparent focus-within:border-brand focus-within:ring-1 focus-within:ring-brand/50\"><span class=\"pl-3 text-sm text-zinc-600\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("@")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 152, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span> <input id=\"username\" name=\"username\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 157, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" placeholder=\"username\" class=\"h-9 w-full bg-transparent px-1.5 pr-3 text-sm font-mono text-zinc-50 placeholder:text-zinc-500 outline-none\"></div><p class=\"text-xs text-zinc-500\">3–30 lowercase letters, numbers, and underscores.</p></div><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"edit-profile-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Save changes", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></form></div></div><!-- Change password modal --><div id=\"change-password-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-4 text-base font-semibold\">Change password</h2><form method=\"POST\" action=\"/account/password\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 181, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("Current password", "old_password", "", PasswordInput("old_password", "old_password", false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("New password", "new_password", "", PasswordInput("new_password", "new_password", false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"change-password-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Update password", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></form></div></div><!-- Delete account confirmation modal --><div id=\"delete-account-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-sm rounded-lg border border-red-900/60 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-2 text-base font-semibold text-red-400\">Delete your account?</h2><p class=\"mb-5 text-sm text-zinc-400\">This permanently anonymizes your data and signs you out. It cannot be undone.</p><form method=\"POST\" action=\"/account/delete\" data-guard><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 202, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"delete-account-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-red-700 px-3 text-sm text-zinc-50 hover:bg-red-600 transition-colors duration-150\">Delete account</button></div></form></div></div><!-- Leave org confirmation modals -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range orgs {
if item.Role != "owner" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("leave-" + item.Org.Slug + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 221, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-2 text-base font-semibold\">Leave ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 223, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "?</h2><p class=\"mb-5 text-sm text-zinc-400\">You won't be able to rejoin without receiving a new invite.</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/account/orgs/" + item.Org.Slug + "/leave"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 225, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" data-guard><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 226, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\"><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("leave-" + item.Org.Slug + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/account.templ`, Line: 230, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-red-700 px-3 text-sm text-zinc-50 hover:bg-red-600 transition-colors duration-150\">Leave organization</button></div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
)
func AdminClientList(csrfToken string, clients []*postgres.AdminClientItem, total int, cursor, nextCursor string, withClaimsOnly bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("Clients - Admin", "/admin/clients", csrfToken, adminClientListBody(csrfToken, clients, total, cursor, nextCursor, withClaimsOnly, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminClientListBody(csrfToken string, clients []*postgres.AdminClientItem, total int, cursor, nextCursor string, withClaimsOnly bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-4\"><div class=\"flex items-end justify-between gap-4\"><div><h1 class=\"text-xl font-semibold tracking-tight\">OAuth2 Clients</h1><p class=\"text-sm text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(total))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 16, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " clients across all orgs</p></div><div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if withClaimsOnly {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"/admin/clients\" class=\"h-8 rounded border border-zinc-700 px-3 text-xs text-zinc-300 hover:text-zinc-50 inline-flex items-center\">Show all</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"/admin/clients?with_claims=1\" class=\"h-8 rounded border border-zinc-700 px-3 text-xs text-zinc-300 hover:text-zinc-50 inline-flex items-center\">With custom claims</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"rounded-lg border border-zinc-800 overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 bg-zinc-900/60\"><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Client ID</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Name</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Org</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Type</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Claims</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Redirect URI</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Created</th><th class=\"px-4 py-2.5\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(clients) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<tr><td colspan=\"8\" class=\"px-4 py-8 text-center text-zinc-500\">No clients registered.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, c := range clients {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr class=\"border-b border-zinc-800/60 last:border-0 hover:bg-zinc-900/40\"><td class=\"px-4 py-3 font-mono text-xs text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(c.ID[:8])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 49, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "…</td><td class=\"px-4 py-3 text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(c.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 50, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td><td class=\"px-4 py-3 text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.OrgName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/orgs/" + c.OrgSlug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 53, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"hover:text-brand\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(c.OrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 53, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"text-zinc-600\">—</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.Public {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"rounded bg-sky-500/10 px-1.5 py-0.5 text-xs text-sky-400\">Public</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"rounded bg-violet-500/10 px-1.5 py-0.5 text-xs text-violet-400\">Confidential</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.ClaimCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/clients/" + c.ID + "/claims"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 67, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" class=\"text-xs text-brand hover:text-brand-hover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(c.ClaimCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 67, Col: 147}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " claims</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/clients/" + c.ID + "/claims"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 69, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"text-xs text-zinc-600 hover:text-zinc-400\">Add</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"px-4 py-3 font-mono text-xs text-zinc-400 max-w-[200px] truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(c.Domain)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 72, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"px-4 py-3 text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(c.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 73, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"px-4 py-3 text-right\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/clients/" + c.ID + "/delete"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 77, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" data-confirm=\"Permanently delete this OAuth2 client? This cannot be undone.\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 80, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <input type=\"hidden\" name=\"confirm\" value=\"yes\"> <button type=\"submit\" class=\"text-xs text-red-400 hover:text-red-300 hover:underline\">Delete</button></form></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if withClaimsOnly {
templ_7745c5c3_Err = cursorPaginationControls("/admin/clients?with_claims=1", cursor, nextCursor).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = cursorPaginationControls("/admin/clients", cursor, nextCursor).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AdminClientClaimsPage(csrfToken string, clientID, clientName string, existing []postgres.ClaimDefinition, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout(clientName+" — Custom Claims - Admin", "/admin/clients", csrfToken, adminClientClaimsBody(csrfToken, clientID, clientName, existing, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminClientClaimsBody(csrfToken string, clientID, clientName string, existing []postgres.ClaimDefinition, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var16 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil {
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"space-y-4\"><div class=\"flex items-center justify-between\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Custom Claims</h1><p class=\"text-sm text-zinc-400 font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 110, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></div><a href=\"/admin/clients\" class=\"text-sm text-zinc-400 hover:text-zinc-50 transition-colors\">← Back to clients</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3\"><h2 class=\"text-sm font-semibold\">Edit Claims</h2><p class=\"text-xs text-zinc-500 font-mono mt-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(clientID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 120, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</p></div><div class=\"p-4 space-y-4\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/clients/" + clientID + "/claims"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 123, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 124, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"><div class=\"overflow-x-auto\"><table class=\"w-full text-sm\" id=\"claims-table\"><thead><tr class=\"border-b border-zinc-800\"><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[30%]\">Key</th><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[12%]\">Type</th><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[28%]\">Value</th><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[22%]\">Destination</th><th class=\"pb-2 w-8\"></th></tr></thead> <tbody id=\"claims-rows\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(existing) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<tr id=\"empty-state-row\"><td colspan=\"5\" class=\"py-6 text-center text-sm text-zinc-500\">No custom claims yet. Add your first claim below.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, d := range existing {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<tr class=\"border-b border-zinc-800/60 last:border-0\"><td class=\"py-2 pr-3\"><input type=\"text\" name=\"key[]\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 145, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\"></td><td class=\"py-2 pr-3\"><select name=\"type[]\" data-type-select class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"string\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "string" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">string</option> <option value=\"number\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "number" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">number</option> <option value=\"boolean\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "boolean" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">boolean</option></select></td><td class=\"py-2 pr-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "boolean" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<select name=\"value[]\" class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Value == "true" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">true</option> <option value=\"false\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Value == "false" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">false</option></select>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<input type=\"text\" name=\"value[]\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_clients.templ`, Line: 161, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td class=\"py-2 pr-3\"><select name=\"destination[]\" class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"token\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "token" || d.Destinations == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Both tokens</option> <option value=\"access_token\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "access_token" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Access token only</option> <option value=\"id_token\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "id_token" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">ID token only</option> <option value=\"userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">UserInfo only</option> <option value=\"access_token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "access_token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Access token + UserInfo</option> <option value=\"id_token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "id_token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">ID token + UserInfo</option> <option value=\"token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Both tokens + UserInfo</option> <option value=\"access_token,id_token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "access_token,id_token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">All</option></select></td><td class=\"py-2\"><button type=\"button\" data-dismiss-parent class=\"h-8 w-8 inline-flex items-center justify-center rounded border border-zinc-800 text-zinc-500 hover:text-red-400 hover:border-red-800 transition-colors\">×</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<tr data-claims-template aria-hidden=\"true\" class=\"hidden border-b border-zinc-800/60\"><td class=\"py-2 pr-3\"><input type=\"text\" name=\"key[]\" value=\"\" placeholder=\"https://example.com/tier\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\"></td><td class=\"py-2 pr-3\"><select name=\"type[]\" data-type-select class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"string\" selected>string</option> <option value=\"number\">number</option> <option value=\"boolean\">boolean</option></select></td><td class=\"py-2 pr-3\"><input type=\"text\" name=\"value[]\" value=\"\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\"></td><td class=\"py-2 pr-3\"><select name=\"destination[]\" class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"token\" selected>Both tokens</option> <option value=\"access_token\">Access token only</option> <option value=\"id_token\">ID token only</option> <option value=\"userinfo\">UserInfo only</option> <option value=\"access_token,userinfo\">Access token + UserInfo</option> <option value=\"id_token,userinfo\">ID token + UserInfo</option> <option value=\"token,userinfo\">Both tokens + UserInfo</option> <option value=\"access_token,id_token,userinfo\">All</option></select></td><td class=\"py-2\"><button type=\"button\" data-dismiss-parent class=\"h-8 w-8 inline-flex items-center justify-center rounded border border-zinc-800 text-zinc-500 hover:text-red-400 hover:border-red-800 transition-colors\">×</button></td></tr></tbody></table></div><div class=\"flex items-center justify-between gap-4\"><p class=\"text-xs text-amber-400/80 border border-amber-400/20 rounded px-3 py-2 bg-amber-400/5\">Changes apply to newly issued tokens only. Existing tokens retain their claims until expiry.</p><div class=\"flex-none\"><button type=\"submit\" class=\"inline-flex h-9 items-center justify-center gap-2 rounded-md bg-brand px-6 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Save claims</button></div></div><div id=\"add-claim-area\"><button type=\"button\" data-add-claim-row class=\"text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">+ Add claim</button><p id=\"max-claims-notice\" class=\"hidden text-xs text-zinc-500\">Maximum 20 claims reached.</p></div></form></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
)
func AdminOrgList(csrfToken string, orgs []postgres.AdminOrgItem, total int, cursor, nextCursor string, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("Orgs - Admin", "/admin/orgs", csrfToken, adminOrgListBody(orgs, total, cursor, nextCursor, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminOrgListBody(orgs []postgres.AdminOrgItem, total int, cursor, nextCursor string, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-4\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Organizations</h1><p class=\"text-sm text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(total))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 16, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " orgs on the platform</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"rounded-lg border border-zinc-800 overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 bg-zinc-900/60\"><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Name</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Slug</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Members</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Clients</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Created</th><th class=\"px-4 py-2.5\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(orgs) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr><td colspan=\"6\" class=\"px-4 py-8 text-center text-zinc-500\">No organizations yet.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, o := range orgs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr class=\"border-b border-zinc-800/60 last:border-0 hover:bg-zinc-900/40\"><td class=\"px-4 py-3 font-medium text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(o.Org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 39, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</td><td class=\"px-4 py-3 font-mono text-xs text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(o.Org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 40, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</td><td class=\"px-4 py-3 tabular-nums text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(o.MemberCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 41, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</td><td class=\"px-4 py-3 tabular-nums text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(o.ClientCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 42, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td class=\"px-4 py-3 text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(o.Org.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 43, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td><td class=\"px-4 py-3 text-right\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/orgs/" + o.Org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 45, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"text-xs text-brand hover:underline\">Manage</a></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = cursorPaginationControls("/admin/orgs", cursor, nextCursor).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AdminOrgDetail(csrfToken string, org *models.Org, members []*models.OrgMembership, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("Org Detail - Admin", "/admin/orgs", csrfToken, adminOrgDetailBody(csrfToken, org, members, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminOrgDetailBody(csrfToken string, org *models.Org, members []*models.OrgMembership, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"space-y-6 max-w-2xl\"><div class=\"flex items-center gap-3\"><a href=\"/admin/orgs\" class=\"text-sm text-zinc-400 hover:text-zinc-200\">← Organizations</a></div><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 66, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm text-zinc-400 font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 67, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"rounded-lg border border-zinc-800 overflow-hidden\"><div class=\"px-4 py-3 border-b border-zinc-800\"><p class=\"text-xs text-zinc-500 uppercase tracking-wide\">Members</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(members) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<p class=\"px-4 py-4 text-sm text-zinc-500\">No members.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 bg-zinc-900/60\"><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Email</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Role</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Joined</th><th class=\"px-4 py-2.5\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, m := range members {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<tr class=\"border-b border-zinc-800/60 last:border-0 hover:bg-zinc-900/40\"><td class=\"px-4 py-3 font-mono text-xs text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.UserEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 89, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 = []any{adminOrgRoleBadgeClass(m.Role)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 91, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span></td><td class=\"px-4 py-3 text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(m.JoinedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 93, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"px-4 py-3 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if m.Role != "owner" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/orgs/" + org.Slug + "/members/" + m.UserID + "/remove"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 98, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" data-confirm=\"Remove this member from the org?\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 101, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <button type=\"submit\" class=\"text-xs text-red-400 hover:underline\">Remove</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div class=\"rounded-lg border border-red-900/40 p-4 space-y-2\"><p class=\"text-sm font-medium text-red-400\">Danger zone</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/orgs/" + org.Slug + "/delete"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 116, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" data-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Permanently delete org '" + org.Slug + "'? All clients and members will be removed.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 117, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_orgs.templ`, Line: 119, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\"> <button type=\"submit\" class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-red-800 px-3 text-sm text-red-400 hover:bg-red-500/10 transition-colors\">Delete org</button></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
)
func AdminLayout(title string, currentPath string, csrfToken string, body templ.Component) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 18, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin=\"anonymous\"><link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/app.css\"></head><body class=\"min-h-screen bg-zinc-950 text-zinc-50\"><header class=\"border-b border-zinc-800 px-6 py-3\"><div class=\"mx-auto flex max-w-5xl items-center justify-between gap-2\"><div class=\"flex shrink-0 items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Logo("sm").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<span class=\"rounded bg-amber-500/10 px-2 py-0.5 text-xs font-semibold text-amber-400 uppercase tracking-wide\">Admin</span></div><div class=\"flex min-w-0 items-center gap-2\"><nav class=\"flex items-center gap-1 min-w-0 overflow-x-auto hide-scrollbar\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 = []any{adminNavClass(currentPath, "/admin")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"/admin\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">Dashboard</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 = []any{adminNavClass(currentPath, "/admin/users")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/users\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">Users</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 = []any{adminNavClass(currentPath, "/admin/clients")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/admin/clients\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">Clients</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 = []any{adminNavClass(currentPath, "/admin/orgs")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<a href=\"/admin/orgs\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">Orgs</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 = []any{adminNavClass(currentPath, "/admin/audit")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/admin/audit\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">Audit</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 = []any{adminNavClass(currentPath, "/admin/grants")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"/admin/grants\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var13).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">Grants</a></nav><span class=\"h-4 w-px bg-zinc-800 mx-1 shrink-0\"></span> <a href=\"/account\" title=\"My account\" aria-label=\"My account\" class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-zinc-400 hover:text-zinc-50 hover:bg-zinc-900 transition-colors duration-150\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2\"></path> <circle cx=\"12\" cy=\"7\" r=\"4\"></circle></svg></a><form method=\"POST\" action=\"/logout\" class=\"shrink-0\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 53, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"> <button type=\"submit\" title=\"Sign out\" aria-label=\"Sign out\" class=\"flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 hover:text-zinc-50 hover:bg-zinc-900 transition-colors duration-150\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"></path> <polyline points=\"16 17 21 12 16 7\"></polyline> <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\"></line></svg></button></form></div></div></header><main class=\"mx-auto max-w-5xl px-6 py-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<script src=\"/static/app.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminNavClass(currentPath, target string) string {
base := "px-3 py-1.5 rounded-md text-sm transition-colors duration-150 "
if currentPath == target || (len(currentPath) > len(target) && currentPath[:len(target)] == target && target != "/admin") {
return base + "bg-zinc-800 text-brand font-medium"
}
return base + "text-zinc-400 hover:text-zinc-50 hover:bg-zinc-900"
}
func AdminDashboard(csrfToken string, userCount, orgCount, clientCount, grantCount int, dbErr bool) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var16 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil {
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("Admin Dashboard - anekdote", "/admin", csrfToken, adminDashboardBody(userCount, orgCount, clientCount, grantCount, dbErr)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminDashboardBody(userCount, orgCount, clientCount, grantCount int, dbErr bool) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var17 := templ.GetChildren(ctx)
if templ_7745c5c3_Var17 == nil {
templ_7745c5c3_Var17 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"space-y-6\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Platform Overview</h1><p class=\"text-sm text-zinc-400\">Live stats across all tenants.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if dbErr {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"rounded-lg border border-amber-800 bg-amber-500/10 px-4 py-3 text-sm text-amber-400\">One or more database queries failed — counts below may be inaccurate.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4\"><a href=\"/admin/users\" class=\"rounded-lg border border-zinc-800 bg-zinc-900/40 p-5 hover:border-zinc-700 transition-colors\"><p class=\"text-sm text-zinc-400\">Total users</p><p class=\"mt-1 text-3xl font-semibold tabular-nums\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(userCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 105, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p></a> <a href=\"/admin/orgs\" class=\"rounded-lg border border-zinc-800 bg-zinc-900/40 p-5 hover:border-zinc-700 transition-colors\"><p class=\"text-sm text-zinc-400\">Organizations</p><p class=\"mt-1 text-3xl font-semibold tabular-nums\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(orgCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 109, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p></a> <a href=\"/admin/clients\" class=\"rounded-lg border border-zinc-800 bg-zinc-900/40 p-5 hover:border-zinc-700 transition-colors\"><p class=\"text-sm text-zinc-400\">OAuth2 clients</p><p class=\"mt-1 text-3xl font-semibold tabular-nums\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(clientCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 113, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p></a> <a href=\"/admin/grants\" class=\"rounded-lg border border-zinc-800 bg-zinc-900/40 p-5 hover:border-zinc-700 transition-colors\"><p class=\"text-sm text-zinc-400\">Active grants</p><p class=\"mt-1 text-3xl font-semibold tabular-nums\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(grantCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 117, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p></a></div><div class=\"rounded-lg border border-zinc-800 p-5 text-sm text-zinc-400 space-y-1\"><p class=\"font-medium text-zinc-300\">Quick links</p><ul class=\"list-disc list-inside space-y-1 mt-2\"><li><a href=\"/admin/users\" class=\"text-brand hover:underline\">View all users →</a></li><li><a href=\"/admin/clients\" class=\"text-brand hover:underline\">View all OAuth clients →</a></li><li><a href=\"/admin/orgs\" class=\"text-brand hover:underline\">View all organizations →</a></li><li><a href=\"/admin/grants\" class=\"text-brand hover:underline\">View all grants →</a></li></ul></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AdminAuditLog(csrfToken string, entries []*postgres.AuditLogEntry, total int, cursor, nextCursor string, filter postgres.AuditFilter, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("Audit Log - Admin", "/admin/audit", csrfToken, adminAuditLogBody(entries, total, cursor, nextCursor, filter, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminAuditLogBody(entries []*postgres.AuditLogEntry, total int, cursor, nextCursor string, filter postgres.AuditFilter, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var23 := templ.GetChildren(ctx)
if templ_7745c5c3_Var23 == nil {
templ_7745c5c3_Var23 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"space-y-4\"><div class=\"flex items-center justify-between\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Audit Log</h1><p class=\"text-sm text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(total))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 141, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " matching entries</p></div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 templ.SafeURL
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(auditExportURL(filter)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 144, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 transition-colors\">Export CSV</a></div><form method=\"GET\" action=\"/admin/audit\" class=\"flex flex-wrap gap-2 items-end\"><div class=\"flex flex-col gap-1\"><label class=\"text-xs text-zinc-500\">Action</label> <select name=\"action\" class=\"rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-200 focus:border-brand focus:outline-none\"><option value=\"\">All actions</option> <option value=\"disable_user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "disable_user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">disable_user</option> <option value=\"enable_user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "enable_user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">enable_user</option> <option value=\"promote_admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "promote_admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">promote_admin</option> <option value=\"demote_admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "demote_admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">demote_admin</option> <option value=\"change_admin_role\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "change_admin_role" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">change_admin_role</option> <option value=\"delete_client\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "delete_client" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">delete_client</option> <option value=\"delete_user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "delete_user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, ">delete_user</option> <option value=\"delete_org\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "delete_org" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">delete_org</option> <option value=\"remove_org_member\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "remove_org_member" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">remove_org_member</option> <option value=\"transfer_org_ownership\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if filter.Action == "transfer_org_ownership" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">transfer_org_ownership</option></select></div><div class=\"flex flex-col gap-1\"><label class=\"text-xs text-zinc-500\">From (YYYY-MM-DD)</label> <input type=\"date\" name=\"from\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(auditFilterDate(filter.From))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 170, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" class=\"rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-200 focus:border-brand focus:outline-none\"></div><div class=\"flex flex-col gap-1\"><label class=\"text-xs text-zinc-500\">To (YYYY-MM-DD)</label> <input type=\"date\" name=\"to\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(auditFilterDate(filter.To))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 179, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" class=\"rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-200 focus:border-brand focus:outline-none\"></div><button type=\"submit\" class=\"rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800\">Filter</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if auditFilterActive(filter) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<a href=\"/admin/audit\" class=\"rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-500 hover:bg-zinc-800\">Clear</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div class=\"rounded-lg border border-zinc-800 overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 bg-zinc-900/60\"><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Timestamp</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Admin</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Action</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Target</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">IP</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<tr><td colspan=\"5\" class=\"px-4 py-8 text-center text-zinc-500\">No audit entries match your filter.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<tr class=\"border-b border-zinc-800/60 last:border-0 hover:bg-zinc-900/40\"><td class=\"px-4 py-3 font-mono text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(e.CreatedAt.Format("2006-01-02 15:04 UTC"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 208, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"px-4 py-3 text-xs text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.AdminID != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 templ.SafeURL
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + *e.AdminID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 211, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" class=\"text-brand hover:underline font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs((*e.AdminID)[:8])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 211, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "…</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span class=\"text-zinc-600\">—</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 = []any{auditActionBadgeClass(string(e.Action))}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var31).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(string(e.Action))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 217, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span></td><td class=\"px-4 py-3 font-mono text-xs text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(auditTargetLink(e.TargetType, e.TargetID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 220, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</td><td class=\"px-4 py-3 font-mono text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(e.IPAddress)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 222, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = cursorPaginationControls("/admin/audit", cursor, nextCursor).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// cursorPaginationControls renders the shared cursor-based "First page / Next" controls.
func cursorPaginationControls(basePath, cursor, nextCursor string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var36 := templ.GetChildren(ctx)
if templ_7745c5c3_Var36 == nil {
templ_7745c5c3_Var36 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if cursor != "" || nextCursor != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<div class=\"flex items-center justify-between text-sm text-zinc-400\"><div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if cursor != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 templ.SafeURL
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(basePath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 238, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" class=\"rounded border border-zinc-700 px-3 py-1 hover:bg-zinc-800\">← First page</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div><div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if nextCursor != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 templ.SafeURL
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("%s%scursor=%s", basePath, paginationSeparator(basePath), nextCursor)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 243, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"rounded border border-zinc-700 px-3 py-1 hover:bg-zinc-800\">Next →</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func paginationSeparator(basePath string) string {
if strings.Contains(basePath, "?") {
return "&"
}
return "?"
}
func auditActionBadgeClass(action string) string {
switch action {
case "disable_user", "delete_client", "delete_user", "delete_org":
return "rounded bg-red-500/10 px-1.5 py-0.5 text-xs text-red-400"
case "enable_user":
return "rounded bg-emerald-500/10 px-1.5 py-0.5 text-xs text-emerald-400"
case "remove_org_member", "demote_admin":
return "rounded bg-amber-500/10 px-1.5 py-0.5 text-xs text-amber-400"
case "promote_admin", "change_admin_role", "transfer_org_ownership":
return "rounded bg-violet-500/10 px-1.5 py-0.5 text-xs text-violet-400"
default:
return "rounded bg-zinc-700/50 px-1.5 py-0.5 text-xs text-zinc-400"
}
}
func auditTargetLink(targetType, targetID string) string {
switch targetType {
case "user":
return "/admin/users/" + targetID
case "client":
return "/admin/clients/" + targetID
}
return targetID
}
func intToStr(n int) string {
return strconv.Itoa(n)
}
func auditFilterDate(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02")
}
func auditFilterActive(f postgres.AuditFilter) bool {
return f.AdminID != nil || f.Action != "" || f.From != nil || f.To != nil
}
func auditExportURL(f postgres.AuditFilter) string {
u := "/admin/audit/export.csv"
params := ""
if f.Action != "" {
params += "&action=" + f.Action
}
if f.AdminID != nil {
params += "&admin_id=" + *f.AdminID
}
if f.From != nil {
params += "&from=" + f.From.Format("2006-01-02")
}
if f.To != nil {
params += "&to=" + f.To.Format("2006-01-02")
}
if params != "" {
u += "?" + params[1:]
}
return u
}
// AdminGrantList renders /admin/grants
func AdminGrantList(csrfToken string, grants []*postgres.AdminGrantRow, total, page, pageSize int, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var39 := templ.GetChildren(ctx)
if templ_7745c5c3_Var39 == nil {
templ_7745c5c3_Var39 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("Grants - anekdote Admin", "/admin/grants", csrfToken, AdminGrantListBody(csrfToken, grants, total, page, pageSize, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AdminGrantListBody(csrfToken string, grants []*postgres.AdminGrantRow, total, page, pageSize int, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var40 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<div class=\"space-y-6\"><div class=\"flex items-center justify-between\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Client Org Grants</h1><p class=\"text-sm text-zinc-400\">All active grants between clients and orgs.</p></div><span class=\"text-sm text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d total", total))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 330, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div class=\"rounded-lg border border-zinc-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(grants) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<div class=\"flex flex-col items-center gap-2 px-4 py-10 text-center\"><p class=\"text-sm text-zinc-400\">No grants found.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500\"><th class=\"px-4 py-2 text-left\">Client</th><th class=\"px-4 py-2 text-left\">Owner Org</th><th class=\"px-4 py-2 text-left\">Granted Org</th><th class=\"px-4 py-2 text-left\">Granted</th><th class=\"px-4 py-2\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, g := range grants {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<tr class=\"border-b border-zinc-800/50 last:border-0\"><td class=\"px-4 py-3\"><div class=\"font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(g.ClientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 355, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div><div class=\"font-mono text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(g.ClientID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 356, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</div></td><td class=\"px-4 py-3 text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(g.OwnerOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 358, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</td><td class=\"px-4 py-3 text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(g.GrantedOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 359, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</td><td class=\"px-4 py-3 text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(g.GrantedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 360, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</td><td class=\"px-4 py-3 text-right\"><button type=\"button\" data-dialog-show=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue("revoke-" + g.ClientID + "-" + g.GrantedOrgID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 364, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" class=\"h-7 rounded border border-red-800/50 px-2 text-xs text-red-400 hover:text-red-300 transition-colors\">Revoke</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><!-- Pagination -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if total > pageSize {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"flex items-center gap-2 justify-end text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if page > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 templ.SafeURL
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/admin/grants?page=%d", page-1)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 379, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"rounded-md border border-zinc-800 px-3 py-1.5 text-zinc-400 hover:text-zinc-50 transition-colors\">← Prev</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span class=\"text-zinc-500\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", page))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 381, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if page*pageSize < total {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var50 templ.SafeURL
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/admin/grants?page=%d", page+1)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 383, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" class=\"rounded-md border border-zinc-800 px-3 py-1.5 text-zinc-400 hover:text-zinc-50 transition-colors\">Next →</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</div><!-- Revoke confirmation dialogs -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, g := range grants {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue("revoke-" + g.ClientID + "-" + g.GrantedOrgID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 391, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl space-y-4\"><h2 class=\"text-base font-semibold\">Revoke ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(g.GrantedOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 393, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "'s access?</h2><p class=\"text-sm text-zinc-400\">This will remove <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(g.GrantedOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 394, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</strong>'s access to <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(g.ClientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 394, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</strong> and invalidate their outstanding tokens.</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var55 templ.SafeURL
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/admin/grants/" + g.ClientID + "/" + g.GrantedOrgID + "/revoke"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 395, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" class=\"space-y-3\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 396, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-xs font-medium text-zinc-400\">Reason (optional)</label> <input type=\"text\" name=\"reason\" maxlength=\"255\" placeholder=\"Policy violation, org request, etc.\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" data-dialog-hide=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue("revoke-" + g.ClientID + "-" + g.GrantedOrgID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin.templ`, Line: 408, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-red-600 px-3 text-sm font-medium text-white hover:bg-red-500 transition-colors\">Revoke access</button></div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
"strings"
)
func AdminUserList(csrfToken string, users []*postgres.UserListItem, total int, cursor, nextCursor string, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("Users - Admin", "/admin/users", csrfToken, adminUserListBody(csrfToken, users, total, cursor, nextCursor, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminUserListBody(csrfToken string, users []*postgres.UserListItem, total int, cursor, nextCursor string, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-4\"><div class=\"flex items-center justify-between\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Users</h1><p class=\"text-sm text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(total))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 18, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " total</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"rounded-lg border border-zinc-800 overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 bg-zinc-900/60\"><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Email</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Name</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Verified</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Status</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Joined</th><th class=\"px-4 py-2.5\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(users) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr><td colspan=\"6\" class=\"px-4 py-8 text-center text-zinc-500\">No users found.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, u := range users {
var templ_7745c5c3_Var4 = []any{disabledRowClass(u.DisabledAt != nil)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><td class=\"px-4 py-3 font-mono text-xs text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 42, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</td><td class=\"px-4 py-3 text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(u.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 43, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if u.IsVerified {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"text-xs text-emerald-400\">Yes</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span class=\"text-xs text-zinc-500\">No</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if u.DisabledAt != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"rounded bg-red-500/10 px-1.5 py-0.5 text-xs text-red-400\">Disabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"rounded bg-emerald-500/10 px-1.5 py-0.5 text-xs text-emerald-400\">Active</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</td><td class=\"px-4 py-3 text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 58, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td class=\"px-4 py-3 text-right\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + u.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 60, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"text-xs text-brand hover:underline\">View</a></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = cursorPaginationControls("/admin/users", cursor, nextCursor).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AdminUserDetail(csrfToken string, user *models.User, orgs []postgres.OrgListItem, isLastAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AdminLayout("User Detail - Admin", "/admin/users", csrfToken, adminUserDetailBody(csrfToken, user, orgs, isLastAdmin, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminUserDetailBody(csrfToken string, user *models.User, orgs []postgres.OrgListItem, isLastAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"space-y-6 max-w-2xl\"><div class=\"flex items-center gap-3\"><a href=\"/admin/users\" class=\"text-sm text-zinc-400 hover:text-zinc-200\">← Users</a></div><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 81, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm text-zinc-400\">User ID: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 82, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"rounded-lg border border-zinc-800 divide-y divide-zinc-800\"><div class=\"px-4 py-3\"><p class=\"text-xs text-zinc-500 uppercase tracking-wide\">Profile</p></div><div class=\"px-4 py-3 flex justify-between text-sm\"><span class=\"text-zinc-400\">Name</span> <span class=\"text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(user.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 91, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span></div><div class=\"px-4 py-3 flex justify-between text-sm\"><span class=\"text-zinc-400\">Email verified</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.IsVerified {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"text-emerald-400\">Yes</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"text-zinc-500\">No</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"px-4 py-3 flex justify-between text-sm\"><span class=\"text-zinc-400\">Status</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.DisabledAt != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"text-red-400\">Disabled since ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(user.DisabledAt.Format("2006-01-02 15:04 UTC"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 104, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"text-emerald-400\">Active</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div><div class=\"px-4 py-3 flex justify-between text-sm\"><span class=\"text-zinc-400\">Admin</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"rounded bg-violet-500/10 px-1.5 py-0.5 text-xs text-violet-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(adminRoleLabel(user.AdminRole))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 112, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"text-zinc-500\">No</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div><div class=\"px-4 py-3 flex justify-between text-sm\"><span class=\"text-zinc-400\">Joined</span> <span class=\"text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(user.CreatedAt.Format("2006-01-02 15:04 UTC"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 119, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span></div></div><div class=\"rounded-lg border border-zinc-800 p-4 space-y-3\"><p class=\"text-sm font-medium text-zinc-300\">Account actions</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.DisabledAt != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + user.ID + "/enable"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 127, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" data-confirm=\"Re-enable this account? The user will regain full access.\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 130, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"> <button type=\"submit\" class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-emerald-700 px-3 text-sm text-emerald-400 hover:bg-emerald-500/10 transition-colors\">Re-enable account</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + user.ID + "/disable"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 139, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" data-confirm=\"Disable this user and revoke all their active sessions?\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 142, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"> <button type=\"submit\" class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-red-800 px-3 text-sm text-red-400 hover:bg-red-500/10 transition-colors\">Disable account</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><div class=\"rounded-lg border border-zinc-800 p-4 space-y-3\"><p class=\"text-sm font-medium text-zinc-300\">Admin access</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + user.ID + "/demote"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 155, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove admin access from " + user.Email + "?")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 156, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 158, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isLastAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" disabled class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-zinc-700 px-3 text-sm text-zinc-500 opacity-40 cursor-not-allowed\" title=\"Cannot remove the last admin\">Remove admin access</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<button type=\"submit\" class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-amber-800 px-3 text-sm text-amber-400 hover:bg-amber-500/10 transition-colors\">Remove admin access</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</form><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 templ.SafeURL
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + user.ID + "/admin-role"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 175, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 177, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"><div class=\"flex items-center gap-2\"><select name=\"role\" class=\"rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-200 focus:border-brand focus:outline-none\"><option value=\"superadmin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.AdminRole == "superadmin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Superadmin — full access</option> <option value=\"readonly\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.AdminRole == "readonly" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Readonly — view only</option> <option value=\"org_admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.AdminRole == "org_admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Org admin — org management</option></select> <button type=\"submit\" class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-violet-800 px-3 text-sm text-violet-400 hover:bg-violet-500/10 transition-colors\">Set role</button></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 templ.SafeURL
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + user.ID + "/promote"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 196, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" data-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Grant admin access to " + user.Email + "? They will have full access to this admin panel.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 197, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 199, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"> <button type=\"submit\" class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-violet-800 px-3 text-sm text-violet-400 hover:bg-violet-500/10 transition-colors\">Grant admin access</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div><div class=\"rounded-lg border border-zinc-800 overflow-hidden\"><div class=\"px-4 py-3 border-b border-zinc-800\"><p class=\"text-xs text-zinc-500 uppercase tracking-wide\">Organization memberships</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(orgs) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"px-4 py-4 text-sm text-zinc-500\">No organization memberships.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 bg-zinc-900/60\"><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Organization</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Role</th><th class=\"px-4 py-2.5 text-left font-medium text-zinc-400\">Members</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, o := range orgs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<tr class=\"border-b border-zinc-800/60 last:border-0\"><td class=\"px-4 py-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 templ.SafeURL
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/orgs/" + o.Org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 226, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"text-brand hover:underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(o.Org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 226, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</a> <span class=\"ml-1 text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(o.Org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 227, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span></td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 = []any{adminOrgRoleBadgeClass(o.Role)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var33).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToTitle(o.Role[:1]) + o.Role[1:])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 230, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></td><td class=\"px-4 py-3 text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(o.MemberCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 232, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div><div class=\"rounded-lg border border-red-900/40 p-4 space-y-2\"><p class=\"text-sm font-medium text-red-400\">Danger zone</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 templ.SafeURL
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + user.ID + "/delete"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 243, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" data-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue("Permanently delete " + user.Email + "? This anonymizes their data and cannot be undone.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 244, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/admin_users.templ`, Line: 246, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\"> <button type=\"submit\" class=\"inline-flex h-8 items-center gap-1.5 rounded-md border border-red-800 px-3 text-sm text-red-400 hover:bg-red-500/10 transition-colors\">Delete user</button></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func disabledRowClass(disabled bool) string {
base := "border-b border-zinc-800/60 last:border-0 hover:bg-zinc-900/40"
if disabled {
return base + " opacity-50"
}
return base
}
func adminOrgRoleBadgeClass(role string) string {
switch role {
case "owner":
return "rounded bg-amber-500/10 px-1.5 py-0.5 text-xs text-amber-400"
case "admin":
return "rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-400"
default:
return "rounded bg-zinc-700/50 px-1.5 py-0.5 text-xs text-zinc-400"
}
}
func adminRoleLabel(role string) string {
switch role {
case "readonly":
return "Admin (readonly)"
case "org_admin":
return "Admin (org)"
default:
return "Admin"
}
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Footer() 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<footer class=\"mt-8 pb-6 text-center text-xs text-zinc-600\">© 2026 anekdote</footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Logo(size string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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)
switch size {
case "sm":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"font-semibold tracking-tight text-sm select-none\">anekdot<span class=\"text-brand\">e•</span></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "lg":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<span class=\"font-semibold tracking-tight text-xl select-none\">anekdot<span class=\"text-brand\">e•</span></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"font-semibold tracking-tight text-base select-none\">anekdot<span class=\"text-brand\">e•</span></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func Alert(kind string, message string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if message != "" {
switch kind {
case "error":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div role=\"alert\" data-testid=\"alert-error\" class=\"flex w-full items-start gap-2.5 rounded-md border border-red-900 bg-red-950/50 px-3 py-2.5 text-sm text-red-400\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line></svg><div class=\"flex-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 26, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><button type=\"button\" class=\"ml-1 opacity-60 hover:opacity-100 transition-opacity\" data-dismiss-parent aria-label=\"Dismiss\">×</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "success":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div role=\"status\" data-testid=\"alert-success\" class=\"flex w-full items-start gap-2.5 rounded-md border border-emerald-900 bg-emerald-950/50 px-3 py-2.5 text-sm text-emerald-400\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0\"><path d=\"M22 11.08V12a10 10 0 1 1-5.93-9.14\"></path><polyline points=\"22 4 12 14.01 9 11.01\"></polyline></svg><div class=\"flex-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 32, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><button type=\"button\" class=\"ml-1 opacity-60 hover:opacity-100 transition-opacity\" data-dismiss-parent aria-label=\"Dismiss\">×</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "info":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div role=\"status\" data-testid=\"alert-info\" class=\"flex w-full items-start gap-2.5 rounded-md border border-sky-900 bg-sky-950/50 px-3 py-2.5 text-sm text-sky-400\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"8\" x2=\"12.01\" y2=\"8\"></line></svg><div class=\"flex-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 38, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><button type=\"button\" class=\"ml-1 opacity-60 hover:opacity-100 transition-opacity\" data-dismiss-parent aria-label=\"Dismiss\">×</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div role=\"alert\" data-testid=\"alert-warning\" class=\"flex w-full items-start gap-2.5 rounded-md border border-amber-900 bg-amber-950/50 px-3 py-2.5 text-sm text-amber-400\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0\"><path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"></path><line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"></line><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"></line></svg><div class=\"flex-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 44, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><button type=\"button\" class=\"ml-1 opacity-60 hover:opacity-100 transition-opacity\" data-dismiss-parent aria-label=\"Dismiss\">×</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
return nil
})
}
func AlertContainer(errMsg string, success string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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)
if errMsg != "" || success != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"mb-3 flex flex-col gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Alert("error", errMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Alert("success", success).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func FormField(label string, id string, errorMsg string, input templ.Component) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"flex flex-col gap-1.5\"><label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 62, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"text-sm font-medium text-zinc-50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 62, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = input.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errorMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<p id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(id + "-error")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 65, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-xs text-red-400 mt-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 65, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func TextInput(id string, name string, inputType string, placeholder string, value string, hasError bool) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var14 := templ.GetChildren(ctx)
if templ_7745c5c3_Var14 == nil {
templ_7745c5c3_Var14 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if hasError {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<input type=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(inputType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 73, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 74, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 75, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" placeholder=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(placeholder)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 76, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 77, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" aria-invalid=\"true\" class=\"h-9 w-full rounded-md border border-red-500 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors duration-150 focus:border-red-400 focus:ring-1 focus:ring-red-400/30\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<input type=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(inputType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 83, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 84, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 85, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" placeholder=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(placeholder)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 86, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 87, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors duration-150 focus:border-brand focus:ring-1 focus:ring-brand/50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func PasswordInput(id string, name string, hasError bool) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var25 := templ.GetChildren(ctx)
if templ_7745c5c3_Var25 == nil {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"relative\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasError {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<input type=\"password\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 98, Col: 11}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 99, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" placeholder=\"••••••••\" aria-invalid=\"true\" class=\"h-9 w-full rounded-md border border-red-500 bg-transparent px-3 pr-10 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors duration-150 focus:border-red-400 focus:ring-1 focus:ring-red-400/30\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<input type=\"password\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 107, Col: 11}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 108, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" placeholder=\"••••••••\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 pr-10 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors duration-150 focus:border-brand focus:ring-1 focus:ring-brand/50\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" data-pw-toggle class=\"absolute right-0 top-0 flex h-full w-9 items-center justify-center text-zinc-500 hover:text-zinc-200 transition-colors\" aria-label=\"Toggle password visibility\"><svg data-eye xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path><circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg> <svg data-eye-off xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"hidden\"><path d=\"M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line></svg></button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func PrimaryButton(text string, testID string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var30 := templ.GetChildren(ctx)
if templ_7745c5c3_Var30 == nil {
templ_7745c5c3_Var30 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if testID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button type=\"submit\" data-testid=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(testID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 129, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98] data-[loading]:pointer-events-none data-[loading]:cursor-wait\"><svg class=\"btn-spinner hidden h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg> <span class=\"btn-text\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 136, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button type=\"submit\" class=\"inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98] data-[loading]:pointer-events-none data-[loading]:cursor-wait\"><svg class=\"btn-spinner hidden h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg> <span class=\"btn-text\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 147, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func OutlineButton(text string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var34 := templ.GetChildren(ctx)
if templ_7745c5c3_Var34 == nil {
templ_7745c5c3_Var34 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-medium text-zinc-50 transition-all duration-150 hover:bg-zinc-900 hover:border-zinc-700 active:scale-[0.98]\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 157, Col: 8}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func DestructiveButton(text string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var36 := templ.GetChildren(ctx)
if templ_7745c5c3_Var36 == nil {
templ_7745c5c3_Var36 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<button type=\"submit\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md bg-red-600 px-3 text-sm font-medium text-zinc-50 transition-all duration-150 hover:bg-red-500 active:scale-[0.98]\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 166, Col: 8}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ScopeItem(icon string, description string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var38 := templ.GetChildren(ctx)
if templ_7745c5c3_Var38 == nil {
templ_7745c5c3_Var38 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<li class=\"flex items-start gap-2.5 text-sm text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
switch icon {
case "mail":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0 text-brand\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"></path></svg> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "eye":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0 text-brand\"><path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path><circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "clock":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0 text-brand\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 6 12 12 16 14\"></polyline></svg> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"mt-0.5 shrink-0 text-brand\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 182, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ClientTrustBadge(clientName string, domain string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var40 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"flex items-center gap-3 rounded-lg border border-zinc-800 bg-zinc-900 p-4\"><div class=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-zinc-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(clientName) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<span class=\"text-lg font-semibold text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(string([]rune(clientName)[0:1]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 190, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div><div class=\"min-w-0\"><p class=\"truncate text-base font-semibold text-zinc-50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 194, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</p><p class=\"truncate text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/components.templ`, Line: 195, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// OrgOption is a selectable organization in the multi-org consent picker.
type OrgOption struct {
ID string
Name string
Slug string
}
func ConsentPage(clientName string, domain string, scopes []string, csrfToken string, req string, errorMsg string, successMsg string, eligibleOrgs []OrgOption, selectedOrgID string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Authorize application - anekdote", ConsentPageBody(clientName, domain, scopes, csrfToken, req, errorMsg, successMsg, eligibleOrgs, selectedOrgID)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ConsentPageBody(clientName string, domain string, scopes []string, csrfToken string, req string, errorMsg string, successMsg string, eligibleOrgs []OrgOption, selectedOrgID string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full space-y-5 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ClientTrustBadge(clientName, domain).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"border-b border-zinc-800\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(eligibleOrgs) > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"space-y-2\"><p class=\"text-sm text-zinc-400\">Choose the organization for this sign-in:</p><div class=\"space-y-1.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, org := range eligibleOrgs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<label class=\"flex cursor-pointer items-center gap-3 rounded-md border border-zinc-700 px-3 py-2.5 hover:border-zinc-500 has-[:checked]:border-brand has-[:checked]:bg-brand/5 transition-colors\"><input type=\"radio\" name=\"selected_org_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(org.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 28, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"accent-brand\" required><div><p class=\"text-sm font-medium text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(org.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 30, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p><p class=\"text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 31, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div></label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div><div class=\"border-b border-zinc-800\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(scopes) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"space-y-2\"><p class=\"text-sm text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 42, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " is asking for:</p><ul class=\"space-y-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, scope := range scopes {
templ_7745c5c3_Err = ScopeItem(scopeIcon(scope), scopeDescription(scope)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</ul></div><div class=\"border-b border-zinc-800\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"POST\" action=\"\" class=\"flex flex-col gap-3\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 53, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if req != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<input type=\"hidden\" name=\"consent_challenge\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(req)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 55, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if selectedOrgID != "" && len(eligibleOrgs) <= 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<input type=\"hidden\" name=\"selected_org_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(selectedOrgID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 58, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"submit\" name=\"accept\" value=\"true\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Authorize ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 66, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</button> <button type=\"submit\" name=\"reject\" value=\"true\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-medium text-zinc-50 transition-all duration-150 hover:bg-zinc-900 hover:border-zinc-700 active:scale-[0.98]\">Cancel</button></form><p class=\"text-center text-xs text-zinc-500\">Org owners can revoke access from organization settings</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func OAuthAccessDeniedPage(clientName string, orgName string, returnURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Access Denied - anekdote", OAuthAccessDeniedBody(clientName, orgName, returnURL)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func OAuthAccessDeniedBody(clientName string, orgName string, returnURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"w-full space-y-5 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow text-center\"><div class=\"flex justify-center\"><div class=\"flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-red-400\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"></line> <line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"></line></svg></div></div><div><h1 class=\"text-lg font-semibold\">Access denied</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if orgName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p class=\"mt-1.5 text-sm text-zinc-400\">You are not a member of <span class=\"text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 101, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span>. Contact an org owner to request access.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"mt-1.5 text-sm text-zinc-400\">You don't have access to <span class=\"text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 106, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span>. Contact the application owner to request access.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div class=\"flex flex-col gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if returnURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(returnURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 114, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Return to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 116, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a href=\"/account\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-medium text-zinc-50 transition-all duration-150 hover:bg-zinc-900 hover:border-zinc-700\">Back to your account</a></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// OAuthAccessDeniedNoGrant is shown when the client has no org grants for any org the user belongs to.
func OAuthAccessDeniedNoGrant(clientName string, returnURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var17 := templ.GetChildren(ctx)
if templ_7745c5c3_Var17 == nil {
templ_7745c5c3_Var17 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Access Denied - anekdote", OAuthAccessDeniedNoGrantBody(clientName, returnURL)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func OAuthAccessDeniedNoGrantBody(clientName string, returnURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"w-full space-y-5 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow text-center\"><div class=\"flex justify-center\"><div class=\"flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/10\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-amber-400\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line> <line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line></svg></div></div><div><h1 class=\"text-lg font-semibold\">No access granted</h1><p class=\"mt-1.5 text-sm text-zinc-400\">None of your organizations have granted <span class=\"text-zinc-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 145, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> access yet. Ask your org owner to add this application from organization settings.</p></div><div class=\"flex flex-col gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if returnURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(returnURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 152, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Return to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/consent.templ`, Line: 154, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<a href=\"/account\" class=\"inline-flex h-9 w-full items-center justify-center rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-medium text-zinc-50 transition-all duration-150 hover:bg-zinc-900 hover:border-zinc-700\">Back to your account</a></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func scopeDescription(scope string) string {
switch scope {
case "openid":
return "Verify your identity"
case "profile":
return "Read your name and profile"
case "email":
return "Read your email address"
case "offline_access":
return "Stay signed in when you're not using the app"
default:
return scope
}
}
func scopeIcon(scope string) string {
switch scope {
case "email":
return "mail"
case "profile":
return "eye"
case "offline_access":
return "clock"
default:
return "check"
}
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func ClientGrantRequestEmail(clientName, requesterOrgName, requestsURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Access request for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/client_grant_request.templ`, Line: 9, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container { max-width: 600px; margin: 40px auto; padding: 20px; }\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);\n\t\t\t\t}\n\t\t\t\t.header { margin-bottom: 24px; }\n\t\t\t\t.header h2 { margin: 0; color: #fafafa; font-size: 24px; font-weight: 600; letter-spacing: -0.025em; }\n\t\t\t\t.content { font-size: 16px; color: #a1a1aa; margin-bottom: 32px; }\n\t\t\t\t.actions { display: flex; gap: 12px; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; }\n\t\t\t\t.btn-approve {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #fbbf24;\n\t\t\t\t\tcolor: #09090b;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tpadding: 12px 28px;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t}\n\t\t\t\t.btn-deny {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: transparent;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tpadding: 12px 28px;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tborder: 1px solid #3f3f46;\n\t\t\t\t}\n\t\t\t\t.note {\n\t\t\t\t\tfont-size: 13px;\n\t\t\t\t\tcolor: #71717a;\n\t\t\t\t\tbackground-color: #18181b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\tpadding: 12px 16px;\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t\ttext-align: left;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><h2>Access request for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/client_grant_request.templ`, Line: 77, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h2></div><div class=\"content\"><p><strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(requesterOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/client_grant_request.templ`, Line: 80, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong> is requesting access to your OAuth2 client <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/client_grant_request.templ`, Line: 80, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> on anekdote.</p><p>Review this request in your dashboard to approve or deny it.</p></div><div class=\"note\"><strong>Note:</strong> Clicking the button below will open your browser. You may be asked to log in if you are not already signed in.</div><div class=\"actions\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(requestsURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/client_grant_request.templ`, Line: 87, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"btn-approve\">Review request</a></div><div class=\"footer\"><p>You are receiving this email because you are an owner or admin of the org that owns ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/client_grant_request.templ`, Line: 90, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ".</p><p>If you did not expect this request, you can safely ignore this email.</p><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func GrantApprovedEmail(clientName, requesterOrgName, clientsURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Access to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_approved.templ`, Line: 9, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " approved - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container { max-width: 600px; margin: 40px auto; padding: 20px; }\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);\n\t\t\t\t}\n\t\t\t\t.header { margin-bottom: 24px; }\n\t\t\t\t.header h2 { margin: 0; color: #fafafa; font-size: 24px; font-weight: 600; letter-spacing: -0.025em; }\n\t\t\t\t.badge {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #14532d;\n\t\t\t\t\tcolor: #86efac;\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tpadding: 4px 12px;\n\t\t\t\t\tborder-radius: 9999px;\n\t\t\t\t\tmargin-bottom: 16px;\n\t\t\t\t}\n\t\t\t\t.content { font-size: 16px; color: #a1a1aa; margin-bottom: 32px; }\n\t\t\t\t.btn {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #fbbf24;\n\t\t\t\t\tcolor: #09090b;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tpadding: 12px 28px;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><div class=\"badge\">Approved</div><h2>Access to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_approved.templ`, Line: 66, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " approved</h2></div><div class=\"content\"><p>Your access request to <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_approved.templ`, Line: 69, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong> has been approved. <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(requesterOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_approved.templ`, Line: 69, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> can now use this client in OAuth2 flows.</p></div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(clientsURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_approved.templ`, Line: 71, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"btn\">View your clients</a><div class=\"footer\"><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func GrantDeniedEmail(clientName, requesterOrgName string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Access request for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_denied.templ`, Line: 9, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " denied - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container { max-width: 600px; margin: 40px auto; padding: 20px; }\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);\n\t\t\t\t}\n\t\t\t\t.header { margin-bottom: 24px; }\n\t\t\t\t.header h2 { margin: 0; color: #fafafa; font-size: 24px; font-weight: 600; letter-spacing: -0.025em; }\n\t\t\t\t.badge {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #450a0a;\n\t\t\t\t\tcolor: #fca5a5;\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tpadding: 4px 12px;\n\t\t\t\t\tborder-radius: 9999px;\n\t\t\t\t\tmargin-bottom: 16px;\n\t\t\t\t}\n\t\t\t\t.content { font-size: 16px; color: #a1a1aa; margin-bottom: 32px; }\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><div class=\"badge\">Denied</div><h2>Access request denied</h2></div><div class=\"content\"><p><strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(requesterOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_denied.templ`, Line: 59, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</strong>'s access request to <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_denied.templ`, Line: 59, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong> was denied by the client owner.</p><p>If you believe this is an error, contact the client owner directly.</p></div><div class=\"footer\"><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func GrantRevokedEmail(clientName, orgName string, adminRevoke bool, reason string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Access to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 9, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " removed - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container { max-width: 600px; margin: 40px auto; padding: 20px; }\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);\n\t\t\t\t}\n\t\t\t\t.header { margin-bottom: 24px; }\n\t\t\t\t.header h2 { margin: 0; color: #fafafa; font-size: 24px; font-weight: 600; letter-spacing: -0.025em; }\n\t\t\t\t.badge {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #431407;\n\t\t\t\t\tcolor: #fdba74;\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tpadding: 4px 12px;\n\t\t\t\t\tborder-radius: 9999px;\n\t\t\t\t\tmargin-bottom: 16px;\n\t\t\t\t}\n\t\t\t\t.content { font-size: 16px; color: #a1a1aa; margin-bottom: 24px; }\n\t\t\t\t.reason-box {\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tbackground-color: #18181b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\tpadding: 12px 16px;\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t\ttext-align: left;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><div class=\"badge\">Access removed</div><h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 66, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "'s access to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 66, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " removed</h2></div><div class=\"content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if adminRevoke {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p>A platform administrator has removed <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 70, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong>'s access to <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 70, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</strong>. Any existing tokens will be invalidated.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p><strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 72, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</strong> removed its access to <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 72, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</strong>. Any existing tokens have been invalidated.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if reason != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"reason-box\"><strong>Reason:</strong> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(reason)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/grant_revoked.templ`, Line: 77, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"footer\"><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func OrgInviteEmail(orgName, inviterEmail, acceptURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>You're invited to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/org_invite.templ`, Line: 9, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container { max-width: 600px; margin: 40px auto; padding: 20px; }\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);\n\t\t\t\t}\n\t\t\t\t.header { margin-bottom: 24px; }\n\t\t\t\t.header h2 { margin: 0; color: #fafafa; font-size: 24px; font-weight: 600; letter-spacing: -0.025em; }\n\t\t\t\t.content { font-size: 16px; color: #a1a1aa; margin-bottom: 32px; }\n\t\t\t\t.btn {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #fbbf24;\n\t\t\t\t\tcolor: #09090b;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tpadding: 12px 28px;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><h2>You're invited to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/org_invite.templ`, Line: 56, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h2></div><div class=\"content\"><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(inviterEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/org_invite.templ`, Line: 59, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " has invited you to join <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/org_invite.templ`, Line: 59, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> on anekdote.</p><p>This invitation link expires in 24 hours.</p></div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(acceptURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/org_invite.templ`, Line: 62, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"btn\">Accept Invitation</a><div class=\"footer\"><p>Or copy this link: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(acceptURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/org_invite.templ`, Line: 64, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p><p>If you did not expect this invitation, you can safely ignore this email.</p><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func OwnershipTransferEmail(orgName, orgURL string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>You are now the owner of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/ownership_transfer.templ`, Line: 9, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container { max-width: 600px; margin: 40px auto; padding: 20px; }\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);\n\t\t\t\t}\n\t\t\t\t.header { margin-bottom: 24px; }\n\t\t\t\t.header h2 { margin: 0; color: #fafafa; font-size: 24px; font-weight: 600; letter-spacing: -0.025em; }\n\t\t\t\t.content { font-size: 16px; color: #a1a1aa; margin-bottom: 32px; }\n\t\t\t\t.btn {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #fbbf24;\n\t\t\t\t\tcolor: #09090b;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tpadding: 12px 28px;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><h2>You are now the owner of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/ownership_transfer.templ`, Line: 56, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h2></div><div class=\"content\"><p>Ownership of <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/ownership_transfer.templ`, Line: 59, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong> has been transferred to you on anekdote.</p><p>You now have full control of this organization.</p></div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(orgURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/ownership_transfer.templ`, Line: 62, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"btn\">Manage ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/ownership_transfer.templ`, Line: 62, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a><div class=\"footer\"><p>If you did not expect this, contact your account administrator.</p><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func PasswordResetEmail(resetLink string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Password Reset - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container {\n\t\t\t\t\tmax-width: 600px;\n\t\t\t\t\tmargin: 40px auto;\n\t\t\t\t\tpadding: 20px;\n\t\t\t\t}\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n\t\t\t\t}\n\t\t\t\t.header {\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t}\n\t\t\t\t.header h2 {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tfont-size: 24px;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tletter-spacing: -0.025em;\n\t\t\t\t}\n\t\t\t\t.content {\n\t\t\t\t\tfont-size: 16px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-bottom: 32px;\n\t\t\t\t}\n\t\t\t\t.button {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #fbbf24;\n\t\t\t\t\tcolor: #18181b !important;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tpadding: 12px 24px;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t\ta {\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\ttext-decoration: underline;\n\t\t\t\t}\n\t\t\t\t.link-text {\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tword-wrap: break-word;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><h2>Password Reset Request</h2></div><div class=\"content\"><p>Hello,</p><p>We received a request to reset your password for your <strong>anekdote</strong> account.</p><p>Click the button below to securely create a new password:</p></div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(resetLink)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/reset_password.templ`, Line: 88, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"button\">Reset Password</a><div class=\"content\"><p>If the button doesn't work, copy and paste this link into your browser:</p><p class=\"link-text\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(resetLink)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/reset_password.templ`, Line: 93, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(resetLink)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/reset_password.templ`, Line: 93, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</a></p></div><div class=\"footer\"><p>If you did not request this, please ignore this email. Your password will remain unchanged.</p><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func SecretRotatedEmail(clientName, orgName string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Secret rotated for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/secret_rotated.templ`, Line: 9, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container { max-width: 600px; margin: 40px auto; padding: 20px; }\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);\n\t\t\t\t}\n\t\t\t\t.header { margin-bottom: 24px; }\n\t\t\t\t.header h2 { margin: 0; color: #fafafa; font-size: 24px; font-weight: 600; letter-spacing: -0.025em; }\n\t\t\t\t.badge {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: #1e3a5f;\n\t\t\t\t\tcolor: #93c5fd;\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tpadding: 4px 12px;\n\t\t\t\t\tborder-radius: 9999px;\n\t\t\t\t\tmargin-bottom: 16px;\n\t\t\t\t}\n\t\t\t\t.content { font-size: 16px; color: #a1a1aa; margin-bottom: 24px; }\n\t\t\t\t.note {\n\t\t\t\t\tfont-size: 13px;\n\t\t\t\t\tcolor: #71717a;\n\t\t\t\t\tbackground-color: #18181b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\tpadding: 12px 16px;\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t\ttext-align: left;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><div class=\"badge\">Secret rotated</div><h2>Client secret rotated for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/secret_rotated.templ`, Line: 66, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h2></div><div class=\"content\"><p>The client secret for <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/secret_rotated.templ`, Line: 69, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong> owned by <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(orgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/secret_rotated.templ`, Line: 69, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> was just rotated. The previous secret is now invalid.</p></div><div class=\"note\"><strong>Action required:</strong> Update your application configuration with the new client secret shown in your dashboard.</div><div class=\"footer\"><p>If you did not perform this action, please contact support immediately.</p><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package email
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func VerifyEmailOTPEmail(otp string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Verify Your Email - anekdote</title><style>\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t}\n\t\t\t\t.container {\n\t\t\t\t\tmax-width: 600px;\n\t\t\t\t\tmargin: 40px auto;\n\t\t\t\t\tpadding: 20px;\n\t\t\t\t}\n\t\t\t\t.card {\n\t\t\t\t\tbackground-color: #09090b;\n\t\t\t\t\tborder: 1px solid #27272a;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tpadding: 32px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tbox-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n\t\t\t\t}\n\t\t\t\t.header {\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t}\n\t\t\t\t.header h2 {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tcolor: #fafafa;\n\t\t\t\t\tfont-size: 24px;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tletter-spacing: -0.025em;\n\t\t\t\t}\n\t\t\t\t.content {\n\t\t\t\t\tfont-size: 16px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-bottom: 32px;\n\t\t\t\t}\n\t\t\t\t.otp-box {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tbackground-color: transparent;\n\t\t\t\t\tborder: 2px solid #fbbf24;\n\t\t\t\t\tcolor: #fbbf24;\n\t\t\t\t\tfont-weight: bold;\n\t\t\t\t\tfont-size: 28px;\n\t\t\t\t\tletter-spacing: 4px;\n\t\t\t\t\tpadding: 16px 32px;\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\tmargin-bottom: 24px;\n\t\t\t\t}\n\t\t\t\t.footer {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tcolor: #a1a1aa;\n\t\t\t\t\tmargin-top: 32px;\n\t\t\t\t\tborder-top: 1px solid #27272a;\n\t\t\t\t\tpadding-top: 24px;\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"container\"><div class=\"card\"><div class=\"header\"><h2>Verify Your Email</h2></div><div class=\"content\"><p>Hello,</p><p>Thank you for registering with <strong>anekdote</strong>! Please use the following 6-digit code to verify your email address and activate your account.</p><p>This code is valid for the next 15 minutes.</p></div><div class=\"otp-box\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(otp)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/email/verify_email.templ`, Line: 85, Col: 11}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"footer\"><p>If you did not request this, please ignore this email. Your account will remain inactive.</p><p>© 2026 anekdote. All rights reserved.</p></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func ForgotPasswordPage(csrfToken string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Forgot Password - anekdote", ForgotPasswordPageBody(csrfToken, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ForgotPasswordPageBody(csrfToken string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full space-y-6 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow\"><div class=\"mb-2\"><a href=\"/login\" class=\"inline-flex items-center gap-1 text-xs text-zinc-400 hover:text-brand transition-colors duration-150\">← Back to sign in</a></div><div class=\"flex flex-col items-center gap-1.5 text-center\"><h2 class=\"text-xl font-semibold tracking-tight\">Forgot your password?</h2><p class=\"text-sm text-zinc-400\">We'll email you a reset link.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<form method=\"POST\" action=\"/forgot-password\" class=\"flex flex-col gap-5\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/forgot_password.templ`, Line: 26, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("Email address", "email", "", TextInput("email", "email", "email", "user@example.com", "", false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Send reset link", "submit-forgot-password").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func BaseLayout(title string, body templ.Component) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/layout.templ`, Line: 9, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin=\"anonymous\"><link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/app.css\"></head><body class=\"min-h-screen bg-zinc-950 text-zinc-50 flex flex-col items-center justify-center px-6 py-10\"><div class=\"mb-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Logo("md").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><main class=\"w-full max-w-md md:max-w-lg\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<script src=\"/static/app.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AccountLayout(title string, currentPath string, csrfToken string, isAdmin bool, body templ.Component) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/layout.templ`, Line: 34, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</title><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin=\"anonymous\"><link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/app.css\"></head><body class=\"min-h-screen bg-zinc-950 text-zinc-50\"><header class=\"border-b border-zinc-800 px-6 py-3\"><div class=\"mx-auto flex max-w-4xl items-center justify-between gap-2\"><div class=\"shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Logo("sm").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><div class=\"flex min-w-0 items-center gap-2\"><nav class=\"flex items-center gap-1 min-w-0 overflow-x-auto hide-scrollbar\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 = []any{accountNavClass(currentPath, "/account")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"/account\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/layout.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">Settings</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 = []any{accountNavClass(currentPath, "/account/orgs")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a href=\"/account/orgs\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/layout.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">Organizations</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 = []any{accountNavClass(currentPath, "/account/apps")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"/account/apps\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/layout.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">Apps</a></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<span class=\"h-4 w-px bg-zinc-800 mx-1 shrink-0\"></span> <a href=\"/admin\" class=\"shrink-0 rounded bg-amber-500/10 px-2 py-0.5 text-xs font-semibold text-amber-400 uppercase tracking-wide hover:bg-amber-500/20 transition-colors\">Admin</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"h-4 w-px bg-zinc-800 mx-1 shrink-0\"></span><form method=\"POST\" action=\"/logout\" class=\"shrink-0\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/layout.templ`, Line: 67, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"> <button type=\"submit\" title=\"Sign out\" aria-label=\"Sign out\" class=\"flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 hover:text-zinc-50 hover:bg-zinc-900 transition-colors duration-150\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"></path> <polyline points=\"16 17 21 12 16 7\"></polyline> <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\"></line></svg></button></form></div></div></header><main class=\"mx-auto max-w-4xl px-6 py-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<script src=\"/static/app.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func accountNavClass(currentPath, target string) string {
base := "px-3 py-1.5 rounded-md text-sm transition-colors duration-150 "
if currentPath == target || (target == "/account/orgs" && len(currentPath) > 13 && currentPath[:13] == "/account/orgs") {
return base + "bg-zinc-800 text-brand font-medium"
}
return base + "text-zinc-400 hover:text-zinc-50 hover:bg-zinc-900"
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func LoginPage(csrfToken, req, email, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Login - anekdote", LoginPageBody(csrfToken, req, email, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func LoginPageBody(csrfToken, req, email, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full space-y-6 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow\"><div class=\"flex flex-col items-center gap-1.5 text-center\"><h2 class=\"text-xl font-semibold tracking-tight\">Welcome back.</h2><p class=\"text-sm text-zinc-400\">Sign in to your account</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<form method=\"POST\" action=\"/login\" class=\"flex flex-col gap-5\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/login.templ`, Line: 17, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if req != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<input type=\"hidden\" name=\"req\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(req)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/login.templ`, Line: 19, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"flex flex-col gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("Email", "email", "", TextInput("email", "email", "email", "user@example.com", email, false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"flex flex-col gap-1.5\"><label for=\"password\" class=\"text-sm font-medium text-zinc-50\">Password</label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PasswordInput("password", "password", false).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Sign in", "submit-login").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"/forgot-password\" class=\"block text-center text-xs text-zinc-400 hover:text-brand transition-colors duration-150\">Forgot your password?</a></form><p class=\"text-center text-xs text-zinc-400\">New here? <a href=\"/register\" class=\"ml-1 font-medium text-zinc-50 hover:text-brand transition-colors duration-150\">Create an account →</a></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/iabhishekrajput/anekdote-auth/internal/models"
"github.com/iabhishekrajput/anekdote-auth/internal/store/postgres"
)
// OrgListPage renders /account/orgs
func OrgListPage(csrfToken string, orgs []postgres.OrgListItem, pendingInvites []OrgPendingInvite, isAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout("Organizations - anekdote", "/account/orgs", csrfToken, isAdmin, OrgListBody(csrfToken, orgs, pendingInvites, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
type OrgPendingInvite struct {
OrgName string
OrgSlug string
InviterEmail string
Role string
Token string
}
func OrgListBody(csrfToken string, orgs []postgres.OrgListItem, pendingInvites []OrgPendingInvite, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div class=\"flex items-center justify-between\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Organizations</h1><p class=\"text-sm text-zinc-400\">Orgs you belong to or own.</p></div><button data-dialog-show=\"create-org-dialog\" class=\"inline-flex h-9 items-center gap-1 rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">+ Create org</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3\"><h2 class=\"text-sm font-semibold\">Your organizations</h2></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(orgs) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"flex flex-col items-center gap-3 px-4 py-10 text-center\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-zinc-700\"><path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path><polyline points=\"9 22 9 12 15 12 15 22\"></polyline></svg><p class=\"text-sm text-zinc-500\">You're not in any organizations yet.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500\"><th class=\"px-4 py-2 text-left\">Name</th><th class=\"px-4 py-2 text-left\">Slug</th><th class=\"px-4 py-2 text-left\">Role</th><th class=\"px-4 py-2 text-left\">Members</th><th class=\"px-4 py-2\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range orgs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr class=\"border-b border-zinc-800/50 last:border-0 transition-colors duration-100 hover:bg-zinc-900/50\"><td class=\"px-4 py-3 font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 62, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</td><td class=\"px-4 py-3 font-mono text-xs text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 63, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 = []any{orgRoleBadgeClass(item.Role)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 65, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></td><td class=\"px-4 py-3 text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.MemberCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 67, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td class=\"px-4 py-3 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Role == "owner" || item.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + item.Org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 70, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"text-xs text-zinc-400 hover:text-brand transition-colors duration-150\">Manage →</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if item.Role == "viewer" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + item.Org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 72, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"text-xs text-zinc-400 hover:text-brand transition-colors duration-150\">View →</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(pendingInvites) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3\"><h2 class=\"text-sm font-semibold\">Pending invitations</h2></div><table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500\"><th class=\"px-4 py-2 text-left\">Org</th><th class=\"px-4 py-2 text-left\">Invited by</th><th class=\"px-4 py-2 text-left\">Role</th><th class=\"px-4 py-2\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, inv := range pendingInvites {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<tr class=\"border-b border-zinc-800/50 last:border-0\"><td class=\"px-4 py-3 font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(inv.OrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 99, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"px-4 py-3 text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(inv.InviterEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 100, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 = []any{orgRoleBadgeClass(inv.Role)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var13).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(inv.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 102, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span></td><td class=\"px-4 py-3 text-right flex gap-2 justify-end\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/join?token=" + inv.Token))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 106, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"inline-flex h-7 items-center rounded-md bg-brand px-2 text-xs font-medium text-zinc-950 transition-all hover:bg-brand-hover active:scale-[0.98]\">Accept</a></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><!-- Create org dialog --><div id=\"create-org-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-4 text-base font-semibold\">Create organization</h2><form method=\"POST\" action=\"/account/orgs\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 123, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Display name</label> <input type=\"text\" name=\"display_name\" required placeholder=\"Acme Corp\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Slug</label> <input type=\"text\" name=\"slug\" required placeholder=\"acme-corp\" pattern=\"[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-mono text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"><p class=\"text-xs text-zinc-500\">Lowercase letters, numbers, hyphens. 3–63 chars.</p></div><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"create-org-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Create", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// OrgDetailPage renders /account/orgs/:slug
func OrgDetailPage(csrfToken string, org *models.Org, members []*models.OrgMembership, pendingEmails []OrgPendingMember, currentUserID string, canEdit bool, isOwner bool, isAdmin bool, grantedClients []*postgres.OrgGrantItem, outgoingRequests []*postgres.GrantRequest, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = AccountLayout(org.DisplayName+" - anekdote", "/account/orgs/"+org.Slug, csrfToken, isAdmin, OrgDetailBody(csrfToken, org, members, pendingEmails, currentUserID, canEdit, isOwner, grantedClients, outgoingRequests, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
type OrgPendingMember struct {
Email string
Role string
Token string
}
func OrgDetailBody(csrfToken string, org *models.Org, members []*models.OrgMembership, pending []OrgPendingMember, currentUserID string, canEdit bool, isOwner bool, grantedClients []*postgres.OrgGrantItem, outgoingRequests []*postgres.GrantRequest, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 173, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h1><p class=\"text-sm text-zinc-400\">slug: <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 174, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<!-- Tab bar --><div class=\"flex border-b border-zinc-800 overflow-x-auto hide-scrollbar\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 181, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"border-b-2 border-brand px-4 py-2 text-sm font-medium text-brand -mb-px whitespace-nowrap\">Members</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 182, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">OAuth Clients</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/explore"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 183, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Explore Apps</a></div><!-- Members table --><div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3\"><h2 class=\"text-sm font-semibold\">Members (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(members)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 189, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ")</h2></div><div class=\"overflow-x-auto\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500\"><th class=\"px-4 py-2 text-left\">Email</th><th class=\"px-4 py-2 text-left\">Role</th><th class=\"px-4 py-2 text-left\">Joined</th><th class=\"px-4 py-2\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, m := range members {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<tr class=\"border-b border-zinc-800/50 last:border-0\"><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(m.UserEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 204, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 = []any{orgRoleBadgeClass(m.Role)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var27).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(m.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 206, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span></td><td class=\"px-4 py-3 text-zinc-400 text-xs\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(m.JoinedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 208, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</td><td class=\"px-4 py-3 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit && m.UserID != currentUserID && m.Role != "owner" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"inline-flex items-center gap-2\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/members/" + m.UserID + "/role"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 212, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"inline-flex items-center gap-1\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 213, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <select name=\"role\" data-autosubmit class=\"h-7 rounded border border-zinc-700 bg-zinc-900 px-1.5 text-xs text-zinc-200 outline-none focus:border-brand\"><option value=\"member\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if m.Role == "member" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">member</option> <option value=\"viewer\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if m.Role == "viewer" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">viewer</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if m.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">admin</option></select></form><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/members/" + m.UserID + "/remove"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 220, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" class=\"inline\" data-guard><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 221, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"> <button type=\"submit\" data-confirm-email=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.UserEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 222, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"text-xs text-red-400 hover:text-red-300\">Remove</button></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, p := range pending {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<tr class=\"border-b border-zinc-800/50 last:border-0\"><td class=\"px-4 py-3 text-zinc-400 italic\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(p.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 231, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 = []any{orgRoleBadgeClass(p.Role)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var37).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 233, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span> <span class=\"ml-1 rounded-full border border-zinc-700 px-1.5 py-0.5 text-xs text-zinc-500\">invited</span></td><td class=\"px-4 py-3 text-zinc-500 text-xs\">pending</td><td class=\"px-4 py-3 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 templ.SafeURL
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/invites/" + p.Token + "/revoke"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 239, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" class=\"inline\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 240, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"> <button type=\"submit\" class=\"text-xs text-zinc-400 hover:text-zinc-50\">Revoke</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</tbody></table></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"rounded-lg border border-zinc-800 p-4 space-y-3\"><h2 class=\"text-sm font-semibold\">Invite a new member</h2><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 templ.SafeURL
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/invites"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 255, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" class=\"flex gap-2 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 256, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"><div class=\"flex-1 flex flex-col gap-1\"><input type=\"email\" name=\"email\" required placeholder=\"colleague@example.com\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><select name=\"role\" class=\"h-9 rounded-md border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"><option value=\"member\">member</option> <option value=\"viewer\">viewer</option> <option value=\"admin\">admin</option></select> <button type=\"submit\" class=\"h-9 rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Send invite</button></form><p class=\"text-xs text-zinc-500\">They'll receive an email with a 24-hour invite link. No account required — they can register on accept.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if isOwner {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<!-- External Access section --> <div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3\"><h2 class=\"text-sm font-semibold\">External Access</h2><p class=\"text-xs text-zinc-500 mt-0.5\">OAuth2 clients from outside this org that can request tokens scoped to it.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(grantedClients) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<div class=\"px-4 py-6 text-center text-sm text-zinc-500\">No external clients have been granted access.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500\"><th class=\"px-4 py-2 text-left\">Client</th><th class=\"px-4 py-2 text-left\">Granted by</th><th class=\"px-4 py-2 text-left\">Date</th><th class=\"px-4 py-2\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, gc := range grantedClients {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<tr class=\"border-b border-zinc-800/50 last:border-0\"><td class=\"px-4 py-3\"><span class=\"font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(gc.ClientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 306, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <span class=\"ml-2 font-mono text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(gc.ClientID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 307, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span></td><td class=\"px-4 py-3 text-zinc-400 text-xs\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(gc.GrantedByEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 309, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</td><td class=\"px-4 py-3 text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(gc.GrantedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 310, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</td><td class=\"px-4 py-3 text-right\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 templ.SafeURL
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/grants/" + gc.ClientID + "/revoke"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 312, Col: 116}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 313, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\"> <button type=\"submit\" class=\"text-xs text-red-400 hover:text-red-300\">Revoke</button></form></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(outgoingRequests) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<div class=\"border-t border-zinc-800 px-4 py-3\"><p class=\"text-xs font-medium text-zinc-400 mb-2\">Pending access requests</p><table class=\"w-full text-sm\"><thead><tr class=\"text-xs uppercase tracking-wide text-zinc-500\"><th class=\"pb-2 text-left\">Client</th><th class=\"pb-2 text-left\">Requested</th><th class=\"pb-2\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, req := range outgoingRequests {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<tr class=\"border-t border-zinc-800/50\"><td class=\"py-2 text-sm font-medium text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(req.ClientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 337, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " <span class=\"ml-1.5 font-mono text-xs text-zinc-600\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(req.ClientID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 338, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span></td><td class=\"py-2 text-xs text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(req.RequestedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 340, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</td><td class=\"py-2 text-right\"><span class=\"rounded-full border border-yellow-600/30 bg-yellow-600/10 px-2 py-0.5 text-xs text-yellow-500\">pending</span></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"border-t border-zinc-800 px-4 py-3 space-y-2\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var53 templ.SafeURL
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/grants"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 351, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"flex gap-2 items-center\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 352, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\"> <input type=\"text\" name=\"client_id\" required placeholder=\"client-id\" class=\"flex-1 h-8 rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-mono text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"> <button type=\"submit\" class=\"h-8 rounded-md bg-brand px-3 text-xs font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Request access</button></form><p class=\"text-xs text-zinc-600\">Paste the client ID of an OAuth2 application you want to allow access to this org. For multi-org clients, this sends an approval request to the client owner.</p></div></div><!-- Delete org section --> <div class=\"rounded-lg border border-red-900/40 bg-red-950/20 px-4 py-3 flex items-center justify-between gap-4\"><div><p class=\"text-sm font-medium text-zinc-200\">Delete organization</p><p class=\"text-xs text-zinc-500 mt-0.5\">Permanently deletes this org and removes all members. Cannot be undone.</p></div><button type=\"button\" data-dialog-show=\"delete-org-dialog\" class=\"shrink-0 h-8 rounded-md border border-red-800 px-3 text-sm text-red-400 hover:bg-red-500/10 transition-colors duration-150\">Delete org</button></div><!-- Delete org confirmation modal --> <div id=\"delete-org-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-sm rounded-lg border border-red-900/60 bg-zinc-950 p-6 shadow-xl space-y-4\"><h2 class=\"text-base font-semibold text-red-400\">Delete ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var55 string
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 385, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "?</h2><p class=\"text-sm text-zinc-400\">All members and clients will be removed. Type the org slug to confirm.</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var56 templ.SafeURL
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/account/orgs/" + org.Slug + "/delete"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 387, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 388, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\"> <input type=\"text\" name=\"confirm_slug\" required placeholder=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 393, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-mono text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-red-600 focus:ring-1 focus:ring-red-600/30\"><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"delete-org-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-red-700 px-3 text-sm text-zinc-50 hover:bg-red-600 transition-colors duration-150\">Delete organization</button></div></form></div></div><!-- Transfer ownership section — owner-only destructive action --> <div id=\"transfer-ownership\" class=\"rounded-lg border border-red-900/40 p-4 space-y-3\"><h2 class=\"text-sm font-semibold text-red-400\">Transfer ownership</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(members) <= 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"text-sm text-zinc-500\">You are the only member. Invite someone before transferring ownership.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<p class=\"text-sm text-zinc-400\">Select a member to become the new owner. You will be immediately removed from this organization.</p><button type=\"button\" data-dialog-show=\"transfer-ownership-dialog\" class=\"h-9 rounded-md border border-red-900/60 px-3 text-sm text-red-400 hover:bg-red-900/20 transition-colors duration-150\">Transfer & Leave</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</div><!-- Transfer ownership modal --> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(members) > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<div id=\"transfer-ownership-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl space-y-4\"><h2 class=\"text-base font-semibold\">Transfer ownership of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 430, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "?</h2><p class=\"text-sm text-zinc-400\">You will immediately lose access to this organization. This cannot be undone.</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var60 templ.SafeURL
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/account/orgs/" + org.Slug + "/transfer-ownership"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 432, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"space-y-4\" data-guard><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var61 string
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 433, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\" for=\"new_owner_id\">New owner</label> <select id=\"new_owner_id\" name=\"new_owner_id\" required class=\"h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"><option value=\"\">— select a member —</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, m := range members {
if m.UserID != currentUserID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.UserID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 445, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(m.UserEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 445, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var64 string
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(m.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 445, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</select></div><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"transfer-ownership-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-red-600 px-3 text-sm font-medium text-white hover:bg-red-500 transition-colors duration-150\">Transfer ownership & Leave</button></div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// OrgClientsPage renders /account/orgs/:slug/clients
func OrgClientsPage(csrfToken string, org *models.Org, canEdit bool, isAdmin bool, clients []*postgres.OrgClient, newClientID, newSecret, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var65 := templ.GetChildren(ctx)
if templ_7745c5c3_Var65 == nil {
templ_7745c5c3_Var65 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout(org.DisplayName+" — Clients - anekdote", "/account/orgs/"+org.Slug+"/clients", csrfToken, isAdmin, OrgClientsBody(csrfToken, org, canEdit, clients, newClientID, newSecret, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func OrgClientsBody(csrfToken string, org *models.Org, canEdit bool, clients []*postgres.OrgClient, newClientID, newSecret, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var66 := templ.GetChildren(ctx)
if templ_7745c5c3_Var66 == nil {
templ_7745c5c3_Var66 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var67 string
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 477, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</h1><p class=\"text-sm text-zinc-400\">slug: <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var68 string
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 478, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</span></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<!-- Tab bar --><div class=\"flex border-b border-zinc-800 overflow-x-auto hide-scrollbar\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var69 templ.SafeURL
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 485, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Members</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var70 templ.SafeURL
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 486, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" class=\"border-b-2 border-brand px-4 py-2 text-sm font-medium text-brand -mb-px whitespace-nowrap\">OAuth Clients</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var71 templ.SafeURL
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/explore"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 487, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Explore Apps</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if newSecret != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<!-- Secret reveal modal — auto-visible after client registration or rotation --> <div id=\"client-secret-modal\" class=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"w-full max-w-lg rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl space-y-5\"><div><h2 class=\"text-base font-semibold\">Save your client secret</h2><p class=\"mt-1 text-sm text-zinc-400\">This is the only time it will be shown. Copy it now — it cannot be recovered.</p></div><div class=\"space-y-3\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-xs font-medium text-zinc-400 uppercase tracking-wide\">Client ID</label><div class=\"flex items-center gap-2\"><input type=\"text\" readonly value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var72 string
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(newClientID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 505, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" class=\"flex-1 h-9 rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs text-zinc-50 outline-none select-all\"> <button type=\"button\" data-copy=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var73 string
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(newClientID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 510, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" class=\"h-9 shrink-0 rounded-md border border-zinc-700 px-3 text-xs text-zinc-400 hover:text-zinc-50 transition-colors\">Copy</button></div></div><div class=\"flex flex-col gap-1.5\"><label class=\"text-xs font-medium text-zinc-400 uppercase tracking-wide\">Client Secret</label><div class=\"flex items-center gap-2\"><input type=\"text\" readonly value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var74 string
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(newSecret)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 521, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" class=\"flex-1 h-9 rounded-md border border-amber-400/40 bg-zinc-900 px-3 font-mono text-xs text-zinc-50 outline-none select-all\"> <button type=\"button\" data-copy=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var75 string
templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(newSecret)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 526, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\" class=\"h-9 shrink-0 rounded-md border border-zinc-700 px-3 text-xs text-zinc-400 hover:text-zinc-50 transition-colors\">Copy</button></div></div></div><div class=\"flex justify-end\"><button type=\"button\" data-dialog-hide=\"client-secret-modal\" class=\"inline-flex h-9 items-center rounded-md bg-brand px-4 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">I've saved my secret</button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<!-- Clients header --><div class=\"flex items-center justify-between\"><h3 class=\"text-sm font-semibold\">OAuth Clients</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<div class=\"flex items-center gap-2\"><button type=\"button\" data-dialog-show=\"register-service-account-dialog\" class=\"inline-flex h-8 items-center gap-1 rounded-md border border-zinc-700 px-3 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-500 hover:text-zinc-50\">+ Service account</button> <button type=\"button\" data-dialog-show=\"register-client-dialog\" class=\"inline-flex h-8 items-center gap-1 rounded-md bg-brand px-3 text-xs font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">+ Register client</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</div><!-- Client list -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(clients) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<div class=\"rounded-lg border border-zinc-800 flex flex-col items-center gap-2 px-4 py-10 text-center\"><p class=\"text-sm text-zinc-400\">No clients registered yet.</p><p class=\"text-xs text-zinc-500\">Clients registered here will carry <span class=\"font-mono\">org_id</span> and <span class=\"font-mono\">org_role</span> as JWT claims.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, client := range clients {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"rounded-lg border border-zinc-800 p-4 space-y-4\"><div class=\"space-y-2\"><div class=\"flex items-start justify-between gap-3\"><div class=\"flex items-center gap-2 min-w-0 flex-wrap\"><span class=\"font-medium text-sm truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var76 string
templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 575, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if client.IsGlobal {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span class=\"shrink-0 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400\">Multi-org</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if client.Public {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"shrink-0 rounded-full border border-sky-400/30 bg-sky-400/10 px-2 py-0.5 text-xs text-sky-400\">Public · PKCE</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if client.Domain == "urn:anekdote:service-account" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<span class=\"shrink-0 rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-xs text-amber-300\">Service account</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"shrink-0 rounded-full border border-violet-400/30 bg-violet-400/10 px-2 py-0.5 text-xs text-violet-400\">Confidential</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<div class=\"flex items-center gap-2 shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if client.IsOwner {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var77 templ.SafeURL
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/edit"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 591, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\" class=\"h-7 rounded border border-zinc-700 px-2 text-xs text-zinc-400 hover:text-zinc-50 transition-colors inline-flex items-center\">Edit</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !client.Public {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<button type=\"button\" data-dialog-show=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var78 string
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue("rotate-" + client.ID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 598, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\" class=\"h-7 rounded border border-zinc-700 px-2 text-xs text-zinc-400 hover:text-zinc-50 transition-colors\">Rotate secret</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<button type=\"button\" data-dialog-show=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var79 string
templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue("delete-" + client.ID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 604, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "\" class=\"h-7 rounded border border-red-800/50 px-2 text-xs text-red-400 hover:text-red-300 transition-colors\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var80 string
templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(deleteClientLabel(client.IsGlobal, client.IsOwner))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 606, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</div><div class=\"space-y-1 text-xs text-zinc-500\"><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">ID:</span> <span class=\"font-mono text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var81 string
templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 613, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</span> <button type=\"button\" data-copy=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var82 string
templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 614, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\" class=\"h-5 rounded border border-zinc-800 px-1.5 text-zinc-600 hover:text-zinc-400 transition-colors\">Copy</button></div><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">Redirect:</span> <span class=\"font-mono text-zinc-400 truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var83 string
templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(client.Domain)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 618, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</span></div><div><span class=\"text-zinc-600\">Created:</span> <span class=\"ml-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var84 string
templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(client.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 622, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</span></div></div></div><!-- Multi-org client subsections: connected orgs + pending requests (owner view only) -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if client.IsGlobal && client.IsOwner {
if len(client.ConnectedOrgs) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<div class=\"border-t border-zinc-800/60 pt-2 space-y-2\"><p class=\"text-xs font-medium text-zinc-500\">Connected orgs</p><table class=\"w-full text-xs\"><thead><tr class=\"text-xs uppercase tracking-wide text-zinc-600\"><th class=\"pb-1 text-left font-medium\">Org</th><th class=\"pb-1 text-left font-medium\">Slug</th><th class=\"pb-1 text-left font-medium\">Granted</th><th class=\"pb-1 text-right font-medium\">Scopes</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, cg := range client.ConnectedOrgs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<tr class=\"border-t border-zinc-800/30\"><td class=\"py-1.5 font-medium text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var85 string
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(cg.OrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 644, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</td><td class=\"py-1.5 font-mono text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var86 string
templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(cg.OrgSlug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 645, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</td><td class=\"py-1.5 text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var87 string
templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(cg.GrantedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 646, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</td><td class=\"py-1.5 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<button type=\"button\" data-dialog-show=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var88 string
templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue("scopes-" + client.ID + "-" + cg.OrgID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 650, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "\" class=\"text-xs text-zinc-500 hover:text-brand transition-colors\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if cg.AllowedScopes != nil && *cg.AllowedScopes != "" {
var templ_7745c5c3_Var89 string
templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(*cg.AllowedScopes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 653, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "All scopes")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
if cg.AllowedScopes != nil && *cg.AllowedScopes != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<span class=\"font-mono text-zinc-600\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var90 string
templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(*cg.AllowedScopes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 660, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<span class=\"text-zinc-700\">All scopes</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit {
for _, cg := range client.ConnectedOrgs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<!-- Per-org scope restriction modal --> <div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var91 string
templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue("scopes-" + client.ID + "-" + cg.OrgID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 673, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl space-y-4\"><h2 class=\"text-base font-semibold\">Scope restriction for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var92 string
templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(cg.OrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 675, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</h2><p class=\"text-sm text-zinc-400\">Enter space-separated scopes to restrict tokens issued to this org's users. Leave blank to allow all scopes.</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var93 templ.SafeURL
templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/grants/" + cg.OrgID + "/scopes"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 677, Col: 142}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var94 string
templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 678, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var94)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "\"> <input type=\"text\" name=\"allowed_scopes\" placeholder=\"e.g. openid email\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if cg.AllowedScopes != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, " value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var95 string
templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.ResolveAttributeValue(*cg.AllowedScopes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 684, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var95)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, " class=\"w-full h-9 rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs text-zinc-50 placeholder:text-zinc-600 outline-none focus:border-brand\"><div class=\"flex justify-end gap-2\"><button type=\"button\" data-dialog-hide=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var96 string
templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue("scopes-" + client.ID + "-" + cg.OrgID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 689, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 hover:bg-brand-hover transition-colors\">Save</button></div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(client.PendingRequests) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<div class=\"border-t border-zinc-800/60 pt-2 space-y-1\"><div class=\"flex items-center justify-between\"><p class=\"text-xs font-medium text-zinc-500\">Pending requests</p><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var97 templ.SafeURL
templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/requests"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 703, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\" class=\"text-xs text-zinc-500 hover:text-brand transition-colors duration-150\">View history →</a></div><table class=\"w-full text-xs\"><tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, req := range client.PendingRequests {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "<tr class=\"border-t border-zinc-800/30\"><td class=\"py-1.5 font-medium text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var98 string
templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(req.RequesterOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 709, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td><td class=\"py-1.5 text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var99 string
templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(req.RequestedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 710, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</td><td class=\"py-1.5 text-right\"><div class=\"inline-flex gap-1\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var100 templ.SafeURL
templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/requests/" + req.ID + "/approve"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 713, Col: 145}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\" class=\"inline\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var101 string
templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 714, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "\"> <button type=\"submit\" class=\"h-6 rounded border border-emerald-700/50 px-2 text-xs text-emerald-400 hover:text-emerald-300 transition-colors\">Approve</button></form><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var102 templ.SafeURL
templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/requests/" + req.ID + "/deny"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 717, Col: 142}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "\" class=\"inline\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var103 string
templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 718, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var103)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "\"> <button type=\"submit\" class=\"h-6 rounded border border-zinc-700 px-2 text-xs text-zinc-400 hover:text-zinc-50 transition-colors\">Deny</button></form></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if client.IsGlobal && client.IsOwner {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<div class=\"border-t border-zinc-800/60 pt-2\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var104 templ.SafeURL
templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/requests"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 730, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "\" class=\"text-xs text-zinc-500 hover:text-brand transition-colors duration-150\">View request history →</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</div><!-- Delete / Remove modal --> <div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var105 string
templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue("delete-" + client.ID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 737, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if client.IsGlobal && !client.IsOwner {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<h2 class=\"text-base font-semibold\">Remove ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var106 string
templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 740, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, " from this org?</h2><p class=\"text-sm text-zinc-400\">This will remove this org's access to the client. The client itself will not be deleted.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<h2 class=\"text-base font-semibold\">Delete ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var107 string
templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 743, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "?</h2><p class=\"text-sm text-zinc-400\">This will permanently remove the client. Active tokens will remain valid until they expire.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var108 templ.SafeURL
templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/delete"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 746, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\" class=\"flex justify-end gap-2\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var109 string
templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 747, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var109)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\"> <button type=\"button\" data-dialog-hide=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var110 string
templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.ResolveAttributeValue("delete-" + client.ID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 748, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var110)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-red-600 px-3 text-sm font-medium text-white hover:bg-red-500 transition-colors\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var111 string
templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(deleteClientLabel(client.IsGlobal, client.IsOwner))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 749, Col: 188}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "</button></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !client.Public {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "<!-- Rotate secret modal --> <div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var112 string
templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.ResolveAttributeValue("rotate-" + client.ID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 756, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var112)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl space-y-4\"><h2 class=\"text-base font-semibold\">Rotate secret for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var113 string
templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 758, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "?</h2><p class=\"text-sm text-zinc-400\">This generates a new secret. New auth flows using the old secret will fail immediately. Existing access tokens remain valid until expiry.</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var114 templ.SafeURL
templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/rotate-secret"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 760, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "\" class=\"flex justify-end gap-2\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var115 string
templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 761, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var115)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "\"> <button type=\"button\" data-dialog-hide=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var116 string
templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.ResolveAttributeValue("rotate-" + client.ID + "-dialog")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 762, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var116)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 hover:bg-brand-hover transition-colors\">Rotate secret</button></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "<!-- Register service account modal --> <div id=\"register-service-account-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-4 text-base font-semibold\">Create service account</h2><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var117 templ.SafeURL
templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 778, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var118 string
templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 779, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var118)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "\"> <input type=\"hidden\" name=\"service_account\" value=\"on\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Service account name</label> <input type=\"text\" name=\"name\" required maxlength=\"255\" placeholder=\"CI deploy bot\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><p class=\"text-xs text-zinc-500\">Creates a confidential org-bound client for the client_credentials grant. Tokens issued to it include this org's org_id.</p><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"register-service-account-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Create service account", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "</div></form></div></div><!-- Register client modal --> <div id=\"register-client-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-4 text-base font-semibold\">Register OAuth client</h2><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var119 templ.SafeURL
templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 805, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var120 string
templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 806, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Client name</label> <input type=\"text\" name=\"name\" required maxlength=\"255\" placeholder=\"My App\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Redirect URI</label> <input type=\"text\" name=\"redirect_uri\" required placeholder=\"https://myapp.example.com/callback\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-mono text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"><p class=\"text-xs text-zinc-500\">Must be an http:// or https:// URI. No wildcards.</p></div><label class=\"flex items-center gap-2 cursor-pointer\"><input type=\"checkbox\" name=\"public\" value=\"on\" class=\"rounded border-zinc-700 bg-zinc-900\"> <span class=\"text-sm\">Public client (PKCE only, no client secret)</span></label><div class=\"border-t border-zinc-800 pt-3 space-y-1.5\"><label class=\"flex items-center gap-2 cursor-pointer\"><input type=\"checkbox\" name=\"multi_org\" value=\"on\" class=\"rounded border-zinc-700 bg-zinc-900\"> <span class=\"text-sm font-medium\">Multi-org client</span></label><p class=\"text-xs text-zinc-500 pl-6\">Allow other orgs to request access to this client. Each request requires your approval before the org can use it.</p></div><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"register-client-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Register client", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
// OrgClientEditPage renders /account/orgs/:slug/clients/:clientID/edit
func OrgClientEditPage(csrfToken string, org *models.Org, client *postgres.OrgClient, canEdit bool, isAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var121 := templ.GetChildren(ctx)
if templ_7745c5c3_Var121 == nil {
templ_7745c5c3_Var121 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout(client.Name+" — Edit - anekdote", "/account/orgs/"+org.Slug+"/clients", csrfToken, isAdmin, OrgClientEditBody(csrfToken, org, client, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func OrgClientEditBody(csrfToken string, org *models.Org, client *postgres.OrgClient, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var122 := templ.GetChildren(ctx)
if templ_7745c5c3_Var122 == nil {
templ_7745c5c3_Var122 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var123 string
templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 858, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</h1><p class=\"text-sm text-zinc-400\">slug: <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var124 string
templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 859, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</span></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<!-- Tab bar --><div class=\"flex border-b border-zinc-800 overflow-x-auto hide-scrollbar\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var125 templ.SafeURL
templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 866, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Members</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var126 templ.SafeURL
templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 867, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "\" class=\"border-b-2 border-brand px-4 py-2 text-sm font-medium text-brand -mb-px whitespace-nowrap\">OAuth Clients</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var127 templ.SafeURL
templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/explore"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 868, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Explore Apps</a></div><div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3 flex items-center justify-between\"><div><h2 class=\"text-sm font-semibold\">Edit client</h2><p class=\"text-xs text-zinc-500 font-mono mt-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var128 string
templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 875, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</p></div><div class=\"flex items-center gap-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var129 templ.SafeURL
templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/claims"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 878, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "\" class=\"inline-flex items-center gap-1 rounded border border-zinc-700 px-2.5 py-1 text-xs text-zinc-300 hover:border-zinc-500 hover:text-zinc-50 transition-colors duration-150\">Custom Claims →</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var130 templ.SafeURL
templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 879, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "\" class=\"text-xs text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">← Back to clients</a></div></div><div class=\"p-4\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var131 templ.SafeURL
templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + client.ID + "/edit"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 883, Col: 107}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var132 string
templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 884, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var132)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Client name</label> <input type=\"text\" name=\"name\" required maxlength=\"255\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var133 string
templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.ResolveAttributeValue(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 892, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var133)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Redirect URI</label> <input type=\"text\" name=\"redirect_uri\" required value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var134 string
templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.ResolveAttributeValue(client.Domain)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 902, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var134)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-mono text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"><p class=\"text-xs text-zinc-500\">Must be an http:// or https:// URI. No wildcards.</p></div><div class=\"flex gap-2 justify-end\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var135 templ.SafeURL
templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 908, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "\" class=\"h-9 inline-flex items-center rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">Cancel</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Save changes", "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</div></form></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// OrgClientRequestHistoryPage renders /account/orgs/:slug/clients/:clientID/requests
func OrgClientRequestHistoryPage(csrfToken string, org *models.Org, client *postgres.OrgClient, requests []*postgres.GrantRequest, isAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var136 := templ.GetChildren(ctx)
if templ_7745c5c3_Var136 == nil {
templ_7745c5c3_Var136 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout(requestHistoryTitle(client)+" — Requests - anekdote", "/account/orgs/"+org.Slug+"/clients", csrfToken, isAdmin, OrgClientRequestHistoryBody(csrfToken, org, client, requests, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func OrgClientRequestHistoryBody(csrfToken string, org *models.Org, client *postgres.OrgClient, requests []*postgres.GrantRequest, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var137 := templ.GetChildren(ctx)
if templ_7745c5c3_Var137 == nil {
templ_7745c5c3_Var137 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var138 string
templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 925, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</h1><p class=\"text-sm text-zinc-400\">slug: <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var139 string
templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 926, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "</span></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<!-- Tab bar --><div class=\"flex border-b border-zinc-800 overflow-x-auto hide-scrollbar\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var140 templ.SafeURL
templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 933, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Members</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var141 templ.SafeURL
templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 934, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "\" class=\"border-b-2 border-brand px-4 py-2 text-sm font-medium text-brand -mb-px whitespace-nowrap\">OAuth Clients</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var142 templ.SafeURL
templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/explore"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 935, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Explore Apps</a></div><div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3 flex items-center justify-between\"><div><h2 class=\"text-sm font-semibold\">Access request history</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if client != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<p class=\"text-xs text-zinc-500 mt-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var143 string
templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 943, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var144 templ.SafeURL
templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 946, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var144))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "\" class=\"text-xs text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">← Back to clients</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(requests) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<div class=\"flex flex-col items-center gap-2 px-4 py-10 text-center\"><p class=\"text-sm text-zinc-400\">No access requests yet.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "<table class=\"w-full text-sm\"><thead><tr class=\"border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500\"><th class=\"px-4 py-2 text-left\">Org</th><th class=\"px-4 py-2 text-left\">Requested</th><th class=\"px-4 py-2 text-left\">Status</th><th class=\"px-4 py-2\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, req := range requests {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<tr class=\"border-b border-zinc-800/50 last:border-0\"><td class=\"px-4 py-3 font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var145 string
templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(req.RequesterOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 965, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</td><td class=\"px-4 py-3 text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var146 string
templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(req.RequestedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 966, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var147 = []any{grantRequestStatusClass(req.Status)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var147...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var148 string
templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var147).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var149 string
templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinStringErrs(req.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 968, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var149))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "</span></td><td class=\"px-4 py-3 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if req.Status == "pending" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "<div class=\"inline-flex gap-1\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var150 templ.SafeURL
templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + req.ClientID + "/requests/" + req.ID + "/approve"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 973, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "\" class=\"inline\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var151 string
templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 974, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var151)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "\"> <button type=\"submit\" class=\"h-6 rounded border border-emerald-700/50 px-2 text-xs text-emerald-400 hover:text-emerald-300 transition-colors\">Approve</button></form><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var152 templ.SafeURL
templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + req.ClientID + "/requests/" + req.ID + "/deny"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 977, Col: 141}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "\" class=\"inline\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var153 string
templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 978, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var153)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\"> <button type=\"submit\" class=\"h-6 rounded border border-zinc-700 px-2 text-xs text-zinc-400 hover:text-zinc-50 transition-colors\">Deny</button></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func requestHistoryTitle(client *postgres.OrgClient) string {
if client != nil {
return client.Name
}
return "Client"
}
func grantRequestStatusClass(status string) string {
switch status {
case "approved":
return "rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400"
case "denied":
return "rounded-full border border-red-400/30 bg-red-400/10 px-2 py-0.5 text-xs text-red-400"
default:
return "rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-xs text-amber-400"
}
}
// InviteEmailMismatch renders the "wrong account" error page for /join.
func InviteEmailMismatch(invitedEmail, currentEmail, logoutAction, csrfToken string, isAdmin bool) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var154 := templ.GetChildren(ctx)
if templ_7745c5c3_Var154 == nil {
templ_7745c5c3_Var154 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout("Wrong account — anekdote", "/", csrfToken, isAdmin,
inviteEmailMismatchBody(invitedEmail, currentEmail, logoutAction, csrfToken)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func inviteEmailMismatchBody(invitedEmail, currentEmail, logoutAction, csrfToken string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var155 := templ.GetChildren(ctx)
if templ_7745c5c3_Var155 == nil {
templ_7745c5c3_Var155 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "<div class=\"w-full max-w-md mx-auto\"><div class=\"rounded-lg border border-zinc-800 p-6 space-y-5\"><div class=\"space-y-1\"><h1 class=\"text-lg font-semibold\">Wrong account</h1><p class=\"text-sm text-zinc-400\">This invite was sent to a different address.</p></div><div class=\"space-y-2 text-sm\"><div class=\"flex items-center gap-2\"><span class=\"w-24 shrink-0 text-zinc-500\">Invited:</span> <span class=\"font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var156 string
templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(invitedEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1027, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</span></div><div class=\"flex items-center gap-2\"><span class=\"w-24 shrink-0 text-zinc-500\">Signed in as:</span> <span class=\"font-medium text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var157 string
templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(currentEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1031, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "</span></div></div><div class=\"flex flex-col gap-2\"><form method=\"POST\" action=\"/logout\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var158 string
templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1036, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var158)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "\"> <input type=\"hidden\" name=\"redirect_to\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var159 string
templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.ResolveAttributeValue(logoutAction)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1037, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var159)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "\"> <button type=\"submit\" class=\"w-full inline-flex h-9 items-center justify-center rounded-md bg-brand px-4 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Switch to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var160 string
templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(invitedEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1041, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "</button></form><a href=\"/account/orgs\" class=\"w-full inline-flex h-9 items-center justify-center rounded-md border border-zinc-800 px-4 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">← Back to organizations</a></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func deleteClientLabel(isGlobal, isOwner bool) string {
if isGlobal && !isOwner {
return "Remove"
}
return "Delete"
}
func orgRoleBadgeClass(role string) string {
switch role {
case "owner":
return "rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-xs font-medium text-amber-400"
case "admin":
return "rounded-full border border-sky-400/30 bg-sky-400/10 px-2 py-0.5 text-xs text-sky-400"
case "viewer":
return "rounded-full border border-teal-400/30 bg-teal-400/10 px-2 py-0.5 text-xs text-teal-400"
default:
return "rounded-full border border-zinc-700 bg-zinc-800 px-2 py-0.5 text-xs text-zinc-400"
}
}
func OrgExploreAppsPage(csrfToken string, org *models.Org, clients []*postgres.DiscoverableClient, nextCursor string, canEdit bool, isOwner bool, isAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var161 := templ.GetChildren(ctx)
if templ_7745c5c3_Var161 == nil {
templ_7745c5c3_Var161 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout("Explore Apps - "+org.DisplayName+" - anekdote", "/account/orgs/"+org.Slug+"/explore", csrfToken, isAdmin, OrgExploreAppsBody(csrfToken, org, clients, nextCursor, canEdit, isOwner, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func OrgExploreAppsBody(csrfToken string, org *models.Org, clients []*postgres.DiscoverableClient, nextCursor string, canEdit bool, isOwner bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var162 := templ.GetChildren(ctx)
if templ_7745c5c3_Var162 == nil {
templ_7745c5c3_Var162 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var163 string
templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1079, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "</h1><p class=\"text-sm text-zinc-400\">slug: <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var164 string
templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1080, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</span></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<!-- Tab bar --><div class=\"flex border-b border-zinc-800 overflow-x-auto hide-scrollbar\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var165 templ.SafeURL
templ_7745c5c3_Var165, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1087, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var165))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Members</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var166 templ.SafeURL
templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1088, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">OAuth Clients</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var167 templ.SafeURL
templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/explore"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1089, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var167))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "\" class=\"border-b-2 border-brand px-4 py-2 text-sm font-medium text-brand -mb-px whitespace-nowrap\">Explore Apps</a></div><div class=\"flex items-center justify-between\"><h3 class=\"text-sm font-semibold\">Explore Apps</h3></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(clients) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<div class=\"rounded-lg border border-zinc-800 flex flex-col items-center gap-2 px-4 py-10 text-center\"><div class=\"flex h-12 w-12 items-center justify-center rounded-full bg-zinc-800/50 mb-2\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-zinc-400\"><path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path> <polyline points=\"3.29 7 12 12 20.71 7\"></polyline> <line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"12\"></line></svg></div><p class=\"text-sm font-medium text-zinc-300\">No new apps available</p><p class=\"text-xs text-zinc-500\">You have already connected to all available multi-org clients, or none exist yet.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "<div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, client := range clients {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "<div class=\"rounded-lg border border-zinc-800 p-4 flex items-center justify-between gap-4\"><div class=\"space-y-2 min-w-0\"><div class=\"flex items-center gap-2 min-w-0 flex-wrap\"><span class=\"font-medium text-sm truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var168 string
templ_7745c5c3_Var168, templ_7745c5c3_Err = templ.JoinStringErrs(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1114, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var168))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if client.Public {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<span class=\"shrink-0 rounded-full border border-sky-400/30 bg-sky-400/10 px-2 py-0.5 text-xs text-sky-400\">Public · PKCE</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "<span class=\"shrink-0 rounded-full border border-violet-400/30 bg-violet-400/10 px-2 py-0.5 text-xs text-violet-400\">Confidential</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "</div><div class=\"space-y-1 text-xs text-zinc-500\"><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">ID:</span> <span class=\"font-mono text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var169 string
templ_7745c5c3_Var169, templ_7745c5c3_Err = templ.JoinStringErrs(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1124, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var169))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "</span> <button type=\"button\" data-copy=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var170 string
templ_7745c5c3_Var170, templ_7745c5c3_Err = templ.ResolveAttributeValue(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1125, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var170)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "\" class=\"h-5 rounded border border-zinc-800 px-1.5 text-zinc-600 hover:text-zinc-400 transition-colors\">Copy</button></div><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">Domain:</span> <span class=\"font-mono text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var171 string
templ_7745c5c3_Var171, templ_7745c5c3_Err = templ.JoinStringErrs(client.Domain)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1129, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var171))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "</span></div><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">Developer:</span> <span class=\"font-medium text-zinc-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var172 string
templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(client.OwnerOrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1133, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "</span> <span class=\"font-mono text-zinc-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var173 string
templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinStringErrs(client.OwnerOrgSlug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1134, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "</span></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isOwner {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "<div class=\"shrink-0\"><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var174 templ.SafeURL
templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/grants"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1140, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var174))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var175 string
templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1141, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var175)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "\"> <input type=\"hidden\" name=\"client_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var176 string
templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.ResolveAttributeValue(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1142, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var176)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "\"> <button type=\"submit\" class=\"inline-flex h-8 items-center justify-center rounded-md bg-brand px-3 text-xs font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Request Access</button></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if nextCursor != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, "<div class=\"flex justify-center pt-2\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var177 templ.SafeURL
templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/explore?cursor=" + nextCursor))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1153, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "\" class=\"inline-flex h-9 items-center rounded-md border border-zinc-800 px-4 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">Load more →</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// DeveloperAppsPage renders /account/apps — cross-org client management portal.
func DeveloperAppsPage(csrfToken string, clients []*postgres.UserClientItem, orgs []postgres.OrgListItem, isAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var178 := templ.GetChildren(ctx)
if templ_7745c5c3_Var178 == nil {
templ_7745c5c3_Var178 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout("Developer Apps - anekdote", "/account/apps", csrfToken, isAdmin, DeveloperAppsBody(csrfToken, clients, orgs, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func DeveloperAppsBody(csrfToken string, clients []*postgres.UserClientItem, orgs []postgres.OrgListItem, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var179 := templ.GetChildren(ctx)
if templ_7745c5c3_Var179 == nil {
templ_7745c5c3_Var179 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div class=\"flex items-center justify-between\"><div><h1 class=\"text-xl font-semibold tracking-tight\">Developer Apps</h1><p class=\"text-sm text-zinc-400\">OAuth clients you manage across all your organizations.</p></div><div class=\"flex items-center gap-2\"><button type=\"button\" data-dialog-show=\"register-service-account-app-dialog\" class=\"inline-flex h-9 items-center rounded-md border border-zinc-700 px-3 text-sm font-medium text-zinc-300 hover:border-zinc-500 hover:text-zinc-50 transition-colors\">+ Service account</button> <button type=\"button\" data-dialog-show=\"register-app-dialog\" class=\"inline-flex h-9 items-center gap-1 rounded-md bg-brand px-3 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">+ Register app</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "<div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3\"><h2 class=\"text-sm font-semibold\">Your apps</h2></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(clients) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "<div class=\"flex flex-col items-center gap-3 px-4 py-10 text-center\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-zinc-700\"><rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\" ry=\"2\"></rect><line x1=\"8\" y1=\"21\" x2=\"16\" y2=\"21\"></line><line x1=\"12\" y1=\"17\" x2=\"12\" y2=\"21\"></line></svg><p class=\"text-sm text-zinc-500\">No apps registered yet.</p><p class=\"text-xs text-zinc-600\">Register an app to start the OAuth2 authorization flow from your org.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "<div class=\"divide-y divide-zinc-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, client := range clients {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "<div class=\"px-4 py-4 flex items-center justify-between gap-4\"><div class=\"space-y-1.5 min-w-0\"><div class=\"flex items-center gap-2 min-w-0 flex-wrap\"><span class=\"font-medium text-sm truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var180 string
templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(client.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1205, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var180))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if client.IsGlobal {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "<span class=\"shrink-0 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400\">Multi-org</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if client.Public {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "<span class=\"shrink-0 rounded-full border border-sky-400/30 bg-sky-400/10 px-2 py-0.5 text-xs text-sky-400\">Public · PKCE</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if client.Domain == "urn:anekdote:service-account" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "<span class=\"shrink-0 rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-xs text-amber-300\">Service account</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, "<span class=\"shrink-0 rounded-full border border-violet-400/30 bg-violet-400/10 px-2 py-0.5 text-xs text-violet-400\">Confidential</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "</div><div class=\"space-y-0.5 text-xs text-zinc-500\"><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">ID:</span> <span class=\"font-mono text-zinc-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var181 string
templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1220, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "</span> <button type=\"button\" data-copy=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var182 string
templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.ResolveAttributeValue(client.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1221, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var182)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "\" class=\"h-5 rounded border border-zinc-800 px-1.5 text-zinc-600 hover:text-zinc-400 transition-colors\">Copy</button></div><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">Redirect:</span> <span class=\"font-mono text-zinc-400 truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var183 string
templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(client.Domain)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1225, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</span></div><div class=\"flex items-center gap-2\"><span class=\"text-zinc-600\">Org:</span> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var184 templ.SafeURL
templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + client.OrgSlug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1229, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var184))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "\" class=\"font-medium text-zinc-300 hover:text-brand transition-colors\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var185 string
templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(client.OrgName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1229, Col: 165}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "</a></div><div><span class=\"text-zinc-600\">Created:</span> <span class=\"ml-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var186 string
templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(client.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1233, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, "</span></div></div></div><div class=\"shrink-0\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var187 templ.SafeURL
templ_7745c5c3_Var187, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + client.OrgSlug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1239, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var187))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "\" class=\"inline-flex h-8 items-center rounded-md border border-zinc-700 px-3 text-xs text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">Manage →</a></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "</div></div><!-- Register service account dialog --><div id=\"register-service-account-app-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-4 text-base font-semibold\">Create service account</h2><form method=\"POST\" action=\"/account/apps\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var188 string
templ_7745c5c3_Var188, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1255, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var188)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "\"> <input type=\"hidden\" name=\"service_account\" value=\"on\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Organization</label> <select name=\"org_id\" required class=\"h-9 w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-50 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"><option value=\"\">Select an organization…</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range orgs {
if item.Role == "owner" || item.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var189 string
templ_7745c5c3_Var189, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Org.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1267, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var189)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var190 string
templ_7745c5c3_Var190, templ_7745c5c3_Err = templ.JoinStringErrs(item.Org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1267, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var190))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 308, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var191 string
templ_7745c5c3_Var191, templ_7745c5c3_Err = templ.JoinStringErrs(item.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1267, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var191))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 309, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 310, "</select></div><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Service account name</label> <input type=\"text\" name=\"name\" required placeholder=\"CI deploy bot\" maxlength=\"255\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><p class=\"text-xs text-zinc-500\">Creates a confidential org-bound client for the client_credentials grant. Tokens issued to it include this org's org_id.</p><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"register-service-account-app-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-brand px-4 text-sm font-medium text-zinc-950 hover:bg-brand-hover active:scale-[0.98]\">Create</button></div></form></div></div><!-- Register app dialog --><div id=\"register-app-dialog\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60\"><div class=\"w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl\"><h2 class=\"mb-4 text-base font-semibold\">Register new app</h2><form method=\"POST\" action=\"/account/apps\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var192 string
templ_7745c5c3_Var192, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1304, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var192)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 311, "\"><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Organization</label> <select name=\"org_id\" required class=\"h-9 w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-50 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"><option value=\"\">Select an organization…</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range orgs {
if item.Role == "owner" || item.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 312, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var193 string
templ_7745c5c3_Var193, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Org.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1315, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var193)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 313, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var194 string
templ_7745c5c3_Var194, templ_7745c5c3_Err = templ.JoinStringErrs(item.Org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1315, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var194))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 314, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var195 string
templ_7745c5c3_Var195, templ_7745c5c3_Err = templ.JoinStringErrs(item.Role)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1315, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var195))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 315, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 316, "</select><p class=\"text-xs text-zinc-500\">Only orgs where you're owner or admin are shown.</p></div><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">App name</label> <input type=\"text\" name=\"name\" required placeholder=\"My App\" maxlength=\"255\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><div class=\"flex flex-col gap-1.5\"><label class=\"text-sm font-medium\">Redirect URI</label> <input type=\"url\" name=\"redirect_uri\" required placeholder=\"https://myapp.example.com/callback\" class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm font-mono text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors focus:border-brand focus:ring-1 focus:ring-brand/50\"></div><div class=\"flex items-center gap-3\"><label class=\"flex items-center gap-2 cursor-pointer\"><input type=\"checkbox\" name=\"public\" class=\"rounded border-zinc-700 bg-zinc-900 text-brand\"> <span class=\"text-sm\">Public client (PKCE)</span></label> <label class=\"flex items-center gap-2 cursor-pointer\"><input type=\"checkbox\" name=\"multi_org\" class=\"rounded border-zinc-700 bg-zinc-900 text-brand\"> <span class=\"text-sm\">Multi-org</span></label></div><div class=\"flex gap-2 justify-end\"><button type=\"button\" data-dialog-hide=\"register-app-dialog\" class=\"h-9 rounded-md border border-zinc-800 px-3 text-sm text-zinc-400 hover:text-zinc-50\">Cancel</button> <button type=\"submit\" class=\"h-9 rounded-md bg-brand px-4 text-sm font-medium text-zinc-950 hover:bg-brand-hover active:scale-[0.98]\">Register</button></div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ClientClaimsPage renders /account/orgs/:slug/clients/:clientID/claims
func ClientClaimsPage(csrfToken string, org *models.Org, clientID, clientName string, existing []postgres.ClaimDefinition, isAdmin bool, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var196 := templ.GetChildren(ctx)
if templ_7745c5c3_Var196 == nil {
templ_7745c5c3_Var196 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AccountLayout(clientName+" — Custom Claims - anekdote", "/account/orgs/"+org.Slug+"/clients", csrfToken, isAdmin, clientClaimsBody(csrfToken, org, clientID, clientName, existing, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func clientClaimsBody(csrfToken string, org *models.Org, clientID, clientName string, existing []postgres.ClaimDefinition, errorMsg, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var197 := templ.GetChildren(ctx)
if templ_7745c5c3_Var197 == nil {
templ_7745c5c3_Var197 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 317, "<div class=\"w-full max-w-3xl mx-auto space-y-6\"><div><h1 class=\"text-xl font-semibold tracking-tight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var198 string
templ_7745c5c3_Var198, templ_7745c5c3_Err = templ.JoinStringErrs(org.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1376, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var198))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 318, "</h1><p class=\"text-sm text-zinc-400\">slug: <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var199 string
templ_7745c5c3_Var199, templ_7745c5c3_Err = templ.JoinStringErrs(org.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1377, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var199))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 319, "</span></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 320, "<!-- Tab bar --><div class=\"flex border-b border-zinc-800 overflow-x-auto hide-scrollbar\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var200 templ.SafeURL
templ_7745c5c3_Var200, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1384, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var200))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 321, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Members</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var201 templ.SafeURL
templ_7745c5c3_Var201, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1385, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var201))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 322, "\" class=\"border-b-2 border-brand px-4 py-2 text-sm font-medium text-brand -mb-px whitespace-nowrap\">OAuth Clients</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var202 templ.SafeURL
templ_7745c5c3_Var202, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/explore"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1386, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var202))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 323, "\" class=\"px-4 py-2 text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150 whitespace-nowrap\">Explore Apps</a></div><div class=\"rounded-lg border border-zinc-800\"><div class=\"border-b border-zinc-800 px-4 py-3 flex items-center justify-between\"><div><h2 class=\"text-sm font-semibold\">Custom Claims</h2><p class=\"text-xs text-zinc-500 mt-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var203 string
templ_7745c5c3_Var203, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1393, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var203))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 324, "</p></div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var204 templ.SafeURL
templ_7745c5c3_Var204, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + clientID + "/edit"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1395, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var204))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 325, "\" class=\"text-xs text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">← Back to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var205 string
templ_7745c5c3_Var205, templ_7745c5c3_Err = templ.JoinStringErrs(clientName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1395, Col: 194}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var205))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 326, " settings</a></div><div class=\"p-4 space-y-4\"><p class=\"text-xs text-zinc-500\">Custom claims are added to tokens by default. Set <strong>Destination</strong> per row to control where each claim appears. Namespaced keys (e.g. <span class=\"font-mono\">https://example.com/tier</span>) are recommended to avoid collisions, but any key is accepted. Numbers are stored as float64 — avoid values larger than 2^53. Scope gates and advanced policy are configurable via the Management API.</p><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var206 templ.SafeURL
templ_7745c5c3_Var206, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/account/orgs/" + org.Slug + "/clients/" + clientID + "/claims"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1406, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var206))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 327, "\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var207 string
templ_7745c5c3_Var207, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1407, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var207)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 328, "\"><div class=\"overflow-x-auto\"><table class=\"w-full text-sm\" id=\"claims-table\"><thead><tr class=\"border-b border-zinc-800\"><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[30%]\">Key</th><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[12%]\">Type</th><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[28%]\">Value</th><th class=\"pb-2 text-left font-medium text-zinc-400 pr-3 w-[22%]\">Destination</th><th class=\"pb-2 w-8\"></th></tr></thead> <tbody id=\"claims-rows\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(existing) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 329, "<tr id=\"empty-state-row\"><td colspan=\"5\" class=\"py-6 text-center text-sm text-zinc-500\">No custom claims yet. Add your first claim below.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, d := range existing {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 330, "<tr class=\"border-b border-zinc-800/60 last:border-0\"><td class=\"py-2 pr-3\"><input type=\"text\" name=\"key[]\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var208 string
templ_7745c5c3_Var208, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1429, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var208)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 331, "\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\"></td><td class=\"py-2 pr-3\"><select name=\"type[]\" data-type-select class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"string\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "string" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 332, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 333, ">string</option> <option value=\"number\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "number" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 334, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 335, ">number</option> <option value=\"boolean\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "boolean" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 336, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 337, ">boolean</option></select></td><td class=\"py-2 pr-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ValueType == "boolean" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 338, "<select name=\"value[]\" class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Value == "true" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 339, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 340, ">true</option> <option value=\"false\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Value == "false" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 341, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 342, ">false</option></select>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 343, "<input type=\"text\" name=\"value[]\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var209 string
templ_7745c5c3_Var209, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/orgs.templ`, Line: 1445, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var209)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 344, "\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 345, "</td><td class=\"py-2 pr-3\"><select name=\"destination[]\" class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"token\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "token" || d.Destinations == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 346, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 347, ">Both tokens</option> <option value=\"access_token\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "access_token" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 348, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 349, ">Access token only</option> <option value=\"id_token\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "id_token" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 350, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 351, ">ID token only</option> <option value=\"userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 352, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 353, ">UserInfo only</option> <option value=\"access_token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "access_token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 354, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 355, ">Access token + UserInfo</option> <option value=\"id_token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "id_token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 356, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 357, ">ID token + UserInfo</option> <option value=\"token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 358, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 359, ">Both tokens + UserInfo</option> <option value=\"access_token,id_token,userinfo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Destinations == "access_token,id_token,userinfo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 360, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 361, ">All</option></select></td><td class=\"py-2\"><button type=\"button\" data-dismiss-parent class=\"h-8 w-8 inline-flex items-center justify-center rounded border border-zinc-800 text-zinc-500 hover:text-red-400 hover:border-red-800 transition-colors\">×</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 362, "<!-- Hidden template row — cloned by data-add-claim-row JS handler --><tr data-claims-template aria-hidden=\"true\" class=\"hidden border-b border-zinc-800/60\"><td class=\"py-2 pr-3\"><input type=\"text\" name=\"key[]\" value=\"\" placeholder=\"https://example.com/tier\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\"></td><td class=\"py-2 pr-3\"><select name=\"type[]\" data-type-select class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"string\" selected>string</option> <option value=\"number\">number</option> <option value=\"boolean\">boolean</option></select></td><td class=\"py-2 pr-3\"><input type=\"text\" name=\"value[]\" value=\"\" class=\"h-8 w-full rounded border border-zinc-800 bg-transparent px-2 text-sm font-mono text-zinc-50 outline-none focus:border-brand focus:ring-1 focus:ring-brand/50\"></td><td class=\"py-2 pr-3\"><select name=\"destination[]\" class=\"h-8 w-full rounded border border-zinc-800 bg-zinc-950 px-2 text-sm text-zinc-50 outline-none focus:border-brand\"><option value=\"token\" selected>Both tokens</option> <option value=\"access_token\">Access token only</option> <option value=\"id_token\">ID token only</option> <option value=\"userinfo\">UserInfo only</option> <option value=\"access_token,userinfo\">Access token + UserInfo</option> <option value=\"id_token,userinfo\">ID token + UserInfo</option> <option value=\"token,userinfo\">Both tokens + UserInfo</option> <option value=\"access_token,id_token,userinfo\">All</option></select></td><td class=\"py-2\"><button type=\"button\" data-dismiss-parent class=\"h-8 w-8 inline-flex items-center justify-center rounded border border-zinc-800 text-zinc-500 hover:text-red-400 hover:border-red-800 transition-colors\">×</button></td></tr></tbody></table></div><div class=\"flex items-center justify-between gap-4\"><p class=\"text-xs text-amber-400/80 border border-amber-400/20 rounded px-3 py-2 bg-amber-400/5\">Changes apply to newly issued tokens only. Existing tokens retain their claims until expiry.</p><div class=\"flex-none\"><button type=\"submit\" class=\"inline-flex h-9 items-center justify-center gap-2 rounded-md bg-brand px-6 text-sm font-medium text-zinc-950 transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]\">Save claims</button></div></div><div id=\"add-claim-area\"><button type=\"button\" data-add-claim-row class=\"text-sm text-zinc-400 hover:text-zinc-50 transition-colors duration-150\">+ Add claim</button><p id=\"max-claims-notice\" class=\"hidden text-xs text-zinc-500\">Maximum 20 claims reached.</p></div></form></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "net/url"
func RegisterPage(csrfToken, inviteEmail, inviteToken, errorMsg, usernameError, emailError, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Register - anekdote", RegisterPageBody(csrfToken, inviteEmail, inviteToken, errorMsg, usernameError, emailError, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func RegisterPageBody(csrfToken, inviteEmail, inviteToken, errorMsg, usernameError, emailError, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full space-y-6 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow\"><div class=\"flex flex-col items-center gap-1.5 text-center\"><h2 class=\"text-xl font-semibold tracking-tight\">Create your account</h2><p class=\"text-sm text-zinc-400\">Start your story.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<form method=\"POST\" action=\"/register\" class=\"flex flex-col gap-5\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/register.templ`, Line: 19, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if inviteToken != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<input type=\"hidden\" name=\"invite_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(inviteToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/register.templ`, Line: 21, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"flex flex-col gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("Full name", "name", "", TextInput("name", "name", "text", "Jane Doe", "", false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"flex flex-col gap-1.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("Username", "username", usernameError, TextInput("username", "username", "text", "johndoe", "", usernameError != "")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span id=\"username-status\" class=\"text-xs\" style=\"display:none\"></span><div id=\"username-suggestions\" class=\"flex flex-wrap gap-1.5\" style=\"display:none\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if inviteEmail != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex flex-col gap-1.5\"><label for=\"email\" class=\"text-sm font-medium text-zinc-50\">Email address</label> <input type=\"email\" id=\"email\" name=\"email\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(inviteEmail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/register.templ`, Line: 38, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" readonly class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-sm text-zinc-400 outline-none cursor-not-allowed opacity-60\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = FormField("Email address", "email", emailError, TextInput("email", "email", "email", "user@example.com", "", emailError != "")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"flex flex-col gap-1.5\"><label for=\"password\" class=\"text-sm font-medium text-zinc-50\">Password</label><div class=\"relative\"><input type=\"password\" id=\"password\" name=\"password\" placeholder=\"••••••••\" data-strength-input class=\"h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 pr-10 text-sm text-zinc-50 placeholder:text-zinc-500 outline-none transition-colors duration-150 focus:border-brand focus:ring-1 focus:ring-brand/50\"> <button type=\"button\" data-pw-toggle class=\"absolute right-0 top-0 flex h-full w-9 items-center justify-center text-zinc-500 hover:text-zinc-200 transition-colors\" aria-label=\"Toggle password visibility\"><svg data-eye xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path><circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg> <svg data-eye-off xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"hidden\"><path d=\"M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line></svg></button></div><div class=\"flex gap-1 h-1 mt-1\" data-strength=\"0\"><div class=\"flex-1 rounded-full\" data-seg=\"1\"></div><div class=\"flex-1 rounded-full\" data-seg=\"2\"></div><div class=\"flex-1 rounded-full\" data-seg=\"3\"></div><div class=\"flex-1 rounded-full\" data-seg=\"4\"></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Create account", "submit-register").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</form><p class=\"text-center text-xs text-zinc-400\">Already have an account? ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if inviteToken != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/login?req=" + url.QueryEscape("/join?token="+inviteToken) + "&email=" + url.QueryEscape(inviteEmail)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/register.templ`, Line: 84, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"ml-1 font-medium text-zinc-50 hover:text-brand transition-colors duration-150\">Sign in →</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"/login\" class=\"ml-1 font-medium text-zinc-50 hover:text-brand transition-colors duration-150\">Sign in →</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func ResendVerificationPage(csrfToken string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Resend Verification - anekdote", ResendVerificationPageBody(csrfToken, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ResendVerificationPageBody(csrfToken string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full space-y-6 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow\"><div class=\"mb-2\"><a href=\"/login\" class=\"inline-flex items-center gap-1 text-xs text-zinc-400 hover:text-brand transition-colors duration-150\">← Back to sign in</a></div><div class=\"flex flex-col items-center gap-1.5 text-center\"><div class=\"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-brand/10\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-brand\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"></path></svg></div><h2 class=\"text-xl font-semibold tracking-tight\">Resend verification email</h2><p class=\"text-sm text-zinc-400\">Enter your email and we'll send a fresh verification code.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if successMsg == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<form method=\"POST\" action=\"/resend-verification\" class=\"flex flex-col gap-5\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/resend_verification.templ`, Line: 30, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("Email address", "email", "", TextInput("email", "email", "email", "user@example.com", "", false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Send new code", "submit-resend").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func ResetPasswordPage(csrfToken string, token string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Reset Password - anekdote", ResetPasswordPageBody(csrfToken, token, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ResetPasswordPageBody(csrfToken string, token string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full space-y-6 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow\"><div class=\"flex flex-col items-center gap-1.5 text-center\"><h2 class=\"text-xl font-semibold tracking-tight\">Set a new password</h2><p class=\"text-sm text-zinc-400\">Choose something you haven't used before.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<form method=\"POST\" action=\"/reset-password\" class=\"flex flex-col gap-5\" data-confirm-form><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/reset_password.templ`, Line: 17, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"> <input type=\"hidden\" name=\"token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/reset_password.templ`, Line: 18, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><div class=\"flex flex-col gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FormField("New password", "password", "", PasswordInput("password", "password", false)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"flex flex-col gap-1.5\"><label for=\"confirm_password\" class=\"text-sm font-medium text-zinc-50\">Confirm password</label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PasswordInput("confirm_password", "confirm_password", false).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p id=\"confirm_password-error\" class=\"hidden text-xs text-red-400 mt-0.5\"></p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Update password", "submit-reset-password").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func VerifyEmailPage(csrfToken string, userID string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = BaseLayout("Verify Email - anekdote", VerifyEmailPageBody(csrfToken, userID, errorMsg, successMsg)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func VerifyEmailPageBody(csrfToken string, userID string, errorMsg string, successMsg string) 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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"w-full space-y-6 rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow text-center\"><div class=\"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-brand/10\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-brand\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"></path></svg></div><div class=\"space-y-1\"><h2 class=\"text-xl font-semibold tracking-tight\">Check your inbox.</h2><p class=\"text-sm text-zinc-400\">Enter the 6-digit code we sent to your email. It expires in 15 minutes.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AlertContainer(errorMsg, successMsg).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<form method=\"POST\" action=\"/verify-email\" class=\"space-y-4\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/verify_email.templ`, Line: 21, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"> <input type=\"hidden\" name=\"user_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(userID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/verify_email.templ`, Line: 22, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"> <input type=\"text\" id=\"otp\" name=\"otp\" placeholder=\"000000\" required maxlength=\"6\" pattern=\"\\d{6}\" autocomplete=\"one-time-code\" inputmode=\"numeric\" class=\"h-11 w-full rounded-md border border-zinc-800 bg-transparent px-3 text-center text-lg tracking-[0.4em] text-zinc-50 placeholder:text-zinc-600 outline-none transition-colors duration-150 focus:border-brand focus:ring-1 focus:ring-brand/50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PrimaryButton("Verify & continue", "submit-verify").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if userID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<form method=\"POST\" action=\"/verify-email/resend\" class=\"pt-2\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/verify_email.templ`, Line: 42, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"> <input type=\"hidden\" name=\"user_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(userID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/ui/verify_email.templ`, Line: 43, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <button type=\"submit\" class=\"w-full text-sm text-zinc-400 hover:text-brand transition-colors duration-150\">Didn't receive a code? Resend it</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"text-xs text-zinc-500 text-center\">Lost the page? <a href=\"/resend-verification\" class=\"text-brand hover:underline\">Enter your email instead</a></p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate