Signed-off-by: brookjeynes me@brookjeynes.dev
+29
input.css
+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
+7
internal/components/header/header.go
+27
internal/components/header/header.templ
+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
-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
+5
-1
internal/server/index.go
+2
-12
internal/server/login.go
+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
+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
+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
+1
internal/server/oauth/consts.go
+22
-16
internal/server/oauth/oauth.go
+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
+1
internal/server/router.go
+5
-1
internal/views/index/index.go
+5
-1
internal/views/index/index.go
+5
-4
internal/views/index/index.templ
+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
-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
-6
internal/views/login/login.go
+23
-12
internal/views/login/login.templ
+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
brookjeynes.dev
submitted
#0
1 commit
expand
collapse
feat(oauth): save user into session storage feat(components): add header feat(router): add logout route refactor(views/login): simplify login form
Signed-off-by: brookjeynes <me@brookjeynes.dev>
expand 0 comments
pull request successfully merged