Discover books, shows, and movies at your level. Track your progress by filling your Shelf with what you find, and share with other language learners. *No dusting required. shlf.space

feat(oauth): save user into session storage feat(components): add header feat(router): add logout route refactor(views/login): simplify login form #7

merged opened by brookjeynes.dev targeting master from push-xnquxnwnymqn
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:4mj54vc4ha3lh32ksxwunnbh/sh.tangled.repo.pull/3mgcyvbvs4b22
+224 -99
Diff #0
+29
input.css
··· 5 5 padding: 2rem 4rem; 6 6 max-width: 42rem; 7 7 } 8 + 9 + @utility button { 10 + display: flex; 11 + align-items: center; 12 + gap: theme(gap.2); 13 + cursor: pointer; 14 + border-width: 1px; 15 + padding: theme(spacing.1) theme(spacing.2); 16 + 17 + @variant hover { 18 + background-color: theme(colors.gray.50) 19 + } 20 + 21 + @variant disabled { 22 + opacity: 0.5; 23 + cursor: not-allowed; 24 + pointer-events: none; 25 + } 26 + } 27 + 28 + @utility input { 29 + padding: theme(spacing.1) theme(spacing.2); 30 + border-width: 1px; 31 + font-size: theme(text.sm); 32 + 33 + @variant placeholder { 34 + opacity: 0.75; 35 + } 36 + }
+7
internal/components/header/header.go
··· 1 + package components 2 + 3 + import "shlf.space/internal/server/oauth" 4 + 5 + type HeaderParams struct { 6 + User *oauth.AccountUser 7 + }
+27
internal/components/header/header.templ
··· 1 + package components 2 + 3 + templ Header(params HeaderParams) { 4 + <header class="w-full sticky top-0 z-50 px-4 mb-8"> 5 + <nav class="flex justify-between items-center mx-auto max-w-7xl h-16"> 6 + <a class="text-xl font-medium" href="/">shlf.space <span class="text-xs font-normal italic">alpha</span></a> 7 + if params.User != nil { 8 + <details class="relative inline-block text-left"> 9 + <summary class="cursor-pointer list-none flex gap-2 items-center"> 10 + <div class="flex items-center justify-center w-7 h-7 rounded-full bg-gray-100"> 11 + <i class="w-4 h-4" data-lucide="user"></i> 12 + </div> 13 + <span class="text-sm">{ params.User.Account.Handle }</span> 14 + </summary> 15 + <div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 w-48 bg-white drop-shadow-sm"> 16 + <button type="button" hx-post="/logout" hx-swap="none" class="button border-none text-sm w-full"> 17 + <i class="w-4 h-4" data-lucide="log-out"></i> 18 + Log out 19 + </button> 20 + </div> 21 + </details> 22 + } else { 23 + <a href="/login">login</a> 24 + } 25 + </nav> 26 + </header> 27 + }
-2
internal/layouts/base/base.templ
··· 10 10 <script src="/static/htmx.min.js" defer></script> 11 11 <script src="/static/lucide.min.js"></script> 12 12 <script src="/static/alpinejs.min.js" defer></script> 13 - <script src="/static/oat.min.js" defer></script> 14 13 <link rel="stylesheet" href="/static/style.css" type="text/css"/> 15 - <link rel="stylesheet" href="/static/oat.min.css" type="text/css"/> 16 14 </head> 17 15 <body class="min-h-screen"> 18 16 <main>
+5 -1
internal/server/index.go
··· 7 7 ) 8 8 9 9 func (s *Server) Index(w http.ResponseWriter, r *http.Request) { 10 - index.IndexPage(index.IndexPageParams{}).Render(r.Context(), w) 10 + user := s.oauth.GetAccountUser(r) 11 + 12 + index.IndexPage(index.IndexPageParams{ 13 + User: user, 14 + }).Render(r.Context(), w) 11 15 }
+2 -12
internal/server/login.go
··· 37 37 38 38 // Basic handle validation 39 39 if !strings.Contains(handle, ".") { 40 - w.Header().Set("Content-Type", "text/html") 41 - login.LoginFormContent(login.LoginFormParams{ 42 - ReturnUrl: returnURL, 43 - Handle: handle, 44 - ErrorMessage: fmt.Sprintf("'%s' is an invalid handle. Did you mean %s.bsky.social?", handle, handle), 45 - }).Render(r.Context(), w) 40 + htmx.HxNotice(w, "login-msg", fmt.Sprintf("'%s' is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 46 41 return 47 42 } 48 43 ··· 52 47 53 48 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 54 49 if err != nil { 55 - w.Header().Set("Content-Type", "text/html") 56 - login.LoginFormContent(login.LoginFormParams{ 57 - ReturnUrl: returnURL, 58 - Handle: handle, 59 - ErrorMessage: fmt.Sprintf("Failed to start auth flow: %v", err), 60 - }).Render(r.Context(), w) 50 + htmx.HxNotice(w, "login-msg", fmt.Sprintf("Failed to start auth flow: %v", err)) 61 51 return 62 52 } 63 53
+17
internal/server/logout.go
··· 1 + package server 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + 7 + "shlf.space/internal/server/htmx" 8 + ) 9 + 10 + func (s *Server) Logout(w http.ResponseWriter, r *http.Request) { 11 + err := s.oauth.DeleteSession(w, r) 12 + if err != nil { 13 + slog.Error("failed to logout", "err", err) 14 + } 15 + 16 + htmx.HxRedirect(w, http.StatusOK, "/login") 17 + }
+80 -5
internal/server/oauth/accounts.go
··· 1 1 package oauth 2 2 3 - import "net/http" 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "time" 7 + ) 4 8 5 - func (o *OAuth) SetAuthReturn(w http.ResponseWriter, r *http.Request, returnURL string) error { 6 - session, err := o.SessionStore.Get(r, AuthReturnName) 9 + type AccountInfo struct { 10 + Did string `json:"did"` 11 + Handle string `json:"handle"` 12 + SessionId string `json:"session_id"` 13 + AddedAt int64 `json:"added_at"` 14 + } 15 + 16 + type AccountUser struct { 17 + Active *User 18 + Account AccountInfo 19 + } 20 + 21 + func (o *OAuth) GetAccount(r *http.Request) *AccountInfo { 22 + session, err := o.SessionStore.Get(r, AccountsName) 23 + if err != nil || session.IsNew { 24 + return &AccountInfo{} 25 + } 26 + 27 + data, ok := session.Values["account"].(string) 28 + if !ok { 29 + return &AccountInfo{} 30 + } 31 + 32 + var account AccountInfo 33 + if err := json.Unmarshal([]byte(data), &account); err != nil { 34 + return &AccountInfo{} 35 + } 36 + 37 + return &account 38 + } 39 + 40 + func (o *OAuth) AddAccount(w http.ResponseWriter, r *http.Request, did, handle, sessionId string) error { 41 + account := AccountInfo{ 42 + Did: did, 43 + Handle: handle, 44 + SessionId: sessionId, 45 + AddedAt: time.Now().Unix(), 46 + } 47 + 48 + session, err := o.SessionStore.Get(r, AccountsName) 7 49 if err != nil { 8 50 return err 9 51 } 10 52 11 - session.Values[AuthReturnURL] = returnURL 12 - session.Options.MaxAge = 60 * 30 53 + data, err := json.Marshal(account) 54 + if err != nil { 55 + return err 56 + } 57 + 58 + session.Values["account"] = string(data) 59 + session.Options.MaxAge = 60 * 60 * 24 * 365 13 60 session.Options.HttpOnly = true 14 61 session.Options.Secure = !o.Config.Core.Dev 15 62 session.Options.SameSite = http.SameSiteLaxMode ··· 17 64 return session.Save(r, w) 18 65 } 19 66 67 + func (o *OAuth) GetAccountUser(r *http.Request) *AccountUser { 68 + user := o.GetUser(r) 69 + if user == nil { 70 + return nil 71 + } 72 + 73 + account := o.GetAccount(r) 74 + return &AccountUser{ 75 + Active: user, 76 + Account: *account, 77 + } 78 + } 79 + 20 80 type AuthReturnInfo struct { 21 81 ReturnURL string 22 82 } 23 83 84 + func (o *OAuth) SetAuthReturn(w http.ResponseWriter, r *http.Request, returnURL string) error { 85 + session, err := o.SessionStore.Get(r, AuthReturnName) 86 + if err != nil { 87 + return err 88 + } 89 + 90 + session.Values[AuthReturnURL] = returnURL 91 + session.Options.MaxAge = 60 * 30 92 + session.Options.HttpOnly = true 93 + session.Options.Secure = !o.Config.Core.Dev 94 + session.Options.SameSite = http.SameSiteLaxMode 95 + 96 + return session.Save(r, w) 97 + } 98 + 24 99 func (o *OAuth) GetAuthReturn(r *http.Request) *AuthReturnInfo { 25 100 session, err := o.SessionStore.Get(r, AuthReturnName) 26 101 if err != nil || session.IsNew {
+1
internal/server/oauth/consts.go
··· 4 4 ClientName = "Shlf" 5 5 ClientURI = "https://shlf.space" 6 6 SessionName = "shlf-oauth-session" 7 + AccountsName = "shlf-account" 7 8 AuthReturnName = "shlf-auth-return" 8 9 AuthReturnURL = "return_url" 9 10 SessionHandle = "handle"
+22 -16
internal/server/oauth/oauth.go
··· 64 64 return nil, err 65 65 } 66 66 67 - sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 67 + sessionStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 68 68 69 69 clientApp := oauth.NewClientApp(&oauthConfig, authStore) 70 70 clientApp.Dir = res.Directory() ··· 76 76 return &OAuth{ 77 77 ClientApp: clientApp, 78 78 Config: config, 79 - SessionStore: sessStore, 79 + SessionStore: sessionStore, 80 80 JwksUri: jwksUri, 81 81 IdResolver: res, 82 82 }, nil 83 83 } 84 84 85 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 85 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessionData *oauth.ClientSessionData) error { 86 86 userSession, err := o.SessionStore.Get(r, SessionName) 87 87 if err != nil { 88 88 return err 89 89 } 90 90 91 - userSession.Values[SessionDid] = sessData.AccountDID.String() 92 - userSession.Values[SessionPds] = sessData.HostURL 93 - userSession.Values[SessionId] = sessData.SessionID 91 + userSession.Values[SessionDid] = sessionData.AccountDID.String() 92 + userSession.Values[SessionPds] = sessionData.HostURL 93 + userSession.Values[SessionId] = sessionData.SessionID 94 94 userSession.Values[SessionAuthenticated] = true 95 95 96 96 if err := userSession.Save(r, w); err != nil { 97 97 return err 98 98 } 99 99 100 - return nil 100 + handle := "" 101 + resolved, err := o.IdResolver.ResolveIdent(r.Context(), sessionData.AccountDID.String()) 102 + if err == nil && resolved.Handle.String() != "" { 103 + handle = resolved.Handle.String() 104 + } 105 + 106 + return o.AddAccount(w, r, sessionData.AccountDID.String(), handle, sessionData.SessionID) 101 107 } 102 108 103 109 func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { ··· 110 116 } 111 117 112 118 d := userSession.Values[SessionDid].(string) 113 - sessDid, err := syntax.ParseDID(d) 119 + sessionDid, err := syntax.ParseDID(d) 114 120 if err != nil { 115 121 return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 116 122 } 117 123 118 - sessId := userSession.Values[SessionId].(string) 124 + sessionId := userSession.Values[SessionId].(string) 119 125 120 - clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 126 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessionDid, sessionId) 121 127 if err != nil { 122 128 return nil, fmt.Errorf("failed to resume session: %w", err) 123 129 } ··· 135 141 } 136 142 137 143 d := userSession.Values[SessionDid].(string) 138 - sessDid, err := syntax.ParseDID(d) 144 + sessionDid, err := syntax.ParseDID(d) 139 145 if err != nil { 140 146 return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 141 147 } 142 148 143 - sessId := userSession.Values[SessionId].(string) 149 + sessionId := userSession.Values[SessionId].(string) 144 150 145 151 // delete the session 146 - err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 152 + err1 := o.ClientApp.Logout(r.Context(), sessionDid, sessionId) 147 153 if err1 != nil { 148 154 err1 = fmt.Errorf("failed to logout: %w", err1) 149 155 } ··· 164 170 } 165 171 166 172 func (o *OAuth) GetUser(r *http.Request) *User { 167 - sess, err := o.ResumeSession(r) 173 + session, err := o.ResumeSession(r) 168 174 if err != nil { 169 175 return nil 170 176 } 171 177 172 178 return &User{ 173 - Did: sess.Data.AccountDID.String(), 174 - Pds: sess.Data.HostURL, 179 + Did: session.Data.AccountDID.String(), 180 + Pds: session.Data.HostURL, 175 181 } 176 182 } 177 183
+1
internal/server/router.go
··· 15 15 16 16 router.Get("/login", s.Login) 17 17 router.Post("/login", s.Login) 18 + router.Post("/logout", s.Logout) 18 19 19 20 router.Mount("/", s.oauth.Router()) 20 21
+5 -1
internal/views/index/index.go
··· 1 1 package index 2 2 3 - type IndexPageParams struct{} 3 + import "shlf.space/internal/server/oauth" 4 + 5 + type IndexPageParams struct { 6 + User *oauth.AccountUser 7 + }
+5 -4
internal/views/index/index.templ
··· 1 1 package index 2 2 3 3 import "shlf.space/internal/layouts/base" 4 + import "shlf.space/internal/components/header" 4 5 5 6 templ IndexPage(params IndexPageParams) { 6 - @layouts.Base(layouts.BaseParams{Title: "home"}) 7 - <div> 8 - <h1>Hello world</h1> 9 - </div> 7 + @layouts.Base(layouts.BaseParams{Title: "home"}) { 8 + @components.Header(components.HeaderParams{User: params.User}) 9 + <div></div> 10 + } 10 11 }
-40
internal/views/login/login-form.templ
··· 1 - package login 2 - 3 - templ LoginFormContent(params LoginFormParams) { 4 - <label 5 - x-init="lucide.createIcons()" 6 - if params.ErrorMessage != "" { 7 - data-field="error" 8 - } else { 9 - data-field 10 - } 11 - > 12 - Handle 13 - <input 14 - id="handle" 15 - name="handle" 16 - type="text" 17 - placeholder="username.bsky.social" 18 - autocapitalize="none" 19 - autocorrect="off" 20 - autocomplete="username" 21 - required 22 - tabindex="1" 23 - value={ params.Handle } 24 - if params.ErrorMessage != "" { 25 - aria-invalid="true" 26 - aria-errormessage="error-message" 27 - } 28 - /> 29 - if params.ErrorMessage != "" { 30 - <div id="error-message" class="error" role="status"> 31 - { params.ErrorMessage } 32 - </div> 33 - } 34 - </label> 35 - <input type="hidden" name="return_url" value={ params.ReturnUrl }/> 36 - <button type="submit" id="login-button" tabindex="2"> 37 - <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 38 - <span>Login</span> 39 - </button> 40 - }
-6
internal/views/login/login.go
··· 4 4 ReturnUrl string 5 5 ErrorCode string 6 6 } 7 - 8 - type LoginFormParams struct { 9 - ReturnUrl string 10 - Handle string 11 - ErrorMessage string 12 - }
+23 -12
internal/views/login/login.templ
··· 5 5 templ LoginPage(params LoginPageParams) { 6 6 @layouts.Base(layouts.BaseParams{Title: "login"}) { 7 7 <div class="container"> 8 - <form 9 - class="group" 10 - hx-post="/login" 11 - hx-swap="innerHTML" 12 - hx-disabled-elt="#login-button" 13 - > 14 - @LoginFormContent(LoginFormParams{ 15 - ReturnUrl: params.ReturnUrl, 16 - Handle: "", 17 - ErrorMessage: "", 18 - }) 8 + <form class="group flex flex-col gap-2" hx-post="/login" hx-swap="none" hx-disabled-elt="#login-button"> 9 + <label> 10 + <span>Handle</span> 11 + <input 12 + class="input" 13 + id="handle" 14 + name="handle" 15 + type="text" 16 + placeholder="username.bsky.social" 17 + autocapitalize="none" 18 + autocorrect="off" 19 + autocomplete="username" 20 + required 21 + tabindex="1" 22 + /> 23 + </label> 24 + <input type="hidden" name="return_url" value={ params.ReturnUrl }/> 25 + <button type="submit" id="login-button" tabindex="2" class="button self-end"> 26 + <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 27 + <span>Login</span> 28 + </button> 19 29 </form> 20 - <div data-field="error"> 30 + <div data-field="error" class="text-red-500"> 21 31 if params.ErrorCode != "" { 22 32 <p class="error"> 23 33 switch (params.ErrorCode) { ··· 33 43 Please try again. 34 44 </p> 35 45 } 46 + <p id="login-msg"></p> 36 47 </div> 37 48 </div> 38 49 }

History

1 round 0 comments
sign up or login to add to the discussion
brookjeynes.dev submitted #0
1 commit
expand
feat(oauth): save user into session storage feat(components): add header feat(router): add logout route refactor(views/login): simplify login form
expand 0 comments
pull request successfully merged