forked from Nixius/authelia
1
0
Fork 0

Compare commits

...

7 Commits

Author SHA1 Message Date
Leopere bd84b0a578
Include remaining template and route changes
Made-with: Cursor
2026-03-03 18:11:38 -05:00
Leopere bbc828fa35
Fall back to LDAP group check when Authelia session is stale
The Remote-Groups header reflects groups at login time. If a user was
added to 'customers' after logging in (via /activate), the dashboard
would show "No Active Subscription". Now checks LDAP directly as
fallback.

Made-with: Cursor
2026-03-03 18:11:31 -05:00
Leopere 1f8f50d50b
Redirect paid-but-not-activated users from landing to /activate
If a logged-in user has a Stripe customer ID but isn't in the customers
group yet, they've paid but haven't activated. Send them to /activate
instead of showing "No Active Subscription".

Made-with: Cursor
2026-03-03 18:07:57 -05:00
Leopere 91c0411b90
Add /resend-reset endpoint so set-password button sends email directly
The welcome page button was linking to Authelia's reset page which
requires an active login session. Now it POSTs to /resend-reset which
calls the Authelia API server-side and sends the email immediately.
Button text updated from "Reset Password" to "Set Password".

Made-with: Cursor
2026-03-03 17:30:38 -05:00
Leopere aa1201560d
Show welcome page for any user not yet in customers group
Previously, users already in LDAP but not yet activated (e.g. webhook
created the user, or lapsed sub) were redirected to the auth-gated
dashboard. Now only active customers (in 'customers' group) skip the
welcome page — everyone else sees onboarding with password reset.

Made-with: Cursor
2026-03-03 17:20:21 -05:00
Leopere c7d19ed20d
Fix success page skipped due to webhook race condition
The webhook was provisioning the user before the success page loaded,
causing IsNew=false and skipping the welcome/onboarding page entirely.

Now:
- Webhook only ensures user+stripe ID as a backstop (no password email)
- Success page is the sole owner of password reset + welcome flow
- Uses group membership (not IsNew) to distinguish new vs returning:
  if already in 'customers' group -> dashboard, otherwise -> welcome

Made-with: Cursor
2026-03-03 17:16:48 -05:00
Leopere 677bef195f
Trigger Authelia password reset email on new user checkout
The triggerPasswordReset function existed but was never called.
New users now receive a set-password email immediately after their
Stripe checkout completes.

Made-with: Cursor
2026-03-03 17:13:37 -05:00
11 changed files with 106 additions and 22 deletions

View File

@ -4,10 +4,27 @@ import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
)
func (a *App) handleResendReset(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
if username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
if err := a.triggerPasswordReset(username); err != nil {
log.Printf("resend-reset: failed for %s: %v", username, err)
http.Error(w, "failed to send email", http.StatusInternalServerError)
return
}
log.Printf("resend-reset: password reset email sent for %s", username)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Password setup email sent. Check your inbox."))
}
func (a *App) triggerPasswordReset(username string) error {
body, _ := json.Marshal(map[string]string{"username": username})

View File

@ -15,6 +15,14 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
remoteGroups := r.Header.Get("Remote-Groups")
isSubscribed := remoteGroups != "" && contains(remoteGroups, "customers")
// Authelia session may be stale (user was added to customers after login).
if !isSubscribed && remoteUser != "" {
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
if inGroup {
isSubscribed = true
}
}
var customerID string
stackDeployed := false
stackRunning := false

View File

@ -25,7 +25,8 @@ type App struct {
func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swarm.Client) http.Handler {
tmplPattern := filepath.Join(cfg.TemplatePath, "pages", "*.html")
tmpl := template.Must(template.ParseGlob(tmplPattern))
partialsPattern := filepath.Join(cfg.TemplatePath, "partials", "*.html")
tmpl := template.Must(template.Must(template.ParseGlob(partialsPattern)).ParseGlob(tmplPattern))
app := &App{
cfg: cfg,
@ -47,6 +48,7 @@ func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swa
r.Get("/dashboard", app.handleDashboard)
r.Post("/stack-manage", app.handleStackManage)
r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/resend-reset", app.handleResendReset)
r.Post("/portal", app.handlePortal)
r.Post("/resubscribe", app.handleResubscribe)
r.Post("/webhook/stripe", app.handleWebhook)

View File

@ -10,10 +10,22 @@ import (
)
func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User")
if contains(r.Header.Get("Remote-Groups"), "customers") {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
return
}
// Logged-in user who paid but hasn't activated yet — send to activate.
if remoteUser != "" {
custID, _ := a.ldap.GetStripeCustomerID(remoteUser)
if custID != "" {
http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
return
}
}
data := map[string]any{
"AppURL": a.cfg.AppURL,
"Commit": version.Commit,
@ -72,18 +84,19 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
return
}
if result.IsNew {
// New user: send password setup email, show onboarding page.
// Group membership and stack deploy happen on /activate after they set a password.
inGroup, _ := a.ldap.IsInGroup(result.Username, "customers")
if result.IsNew || !inGroup {
// New or lapsed customer: send password setup email, show onboarding.
// Group membership and stack deploy happen on /activate after they log in.
if err := a.triggerPasswordReset(result.Username); err != nil {
log.Printf("authelia reset trigger failed for %s: %v", username, err)
}
data := map[string]any{
"Username": result.Username,
"IsNew": true,
"IsNew": result.IsNew,
"Email": email,
"LoginURL": a.cfg.AutheliaURL,
"ResetURL": a.cfg.AutheliaURL + "/#/reset-password/step1",
"ActivateURL": a.cfg.AppURL + "/activate",
"DashboardURL": a.cfg.AppURL + "/dashboard",
"InstanceURL": "https://" + result.Username + "." + a.cfg.CustomerDomain,
@ -95,17 +108,7 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
return
}
// Existing user resubscribing: re-add to customers group if needed and
// ensure their stack is running, then send straight to dashboard.
inGroup, _ := a.ldap.IsInGroup(result.Username, "customers")
if !inGroup {
if err := a.ldap.AddToGroup(result.Username, "customers"); err != nil {
log.Printf("resubscribe: add to group failed for %s: %v", result.Username, err)
} else {
log.Printf("resubscribe: re-added %s to customers group", result.Username)
}
}
// Returning active customer: ensure stack exists, go to dashboard
stackName := fmt.Sprintf("customer-%s", result.Username)
exists, _ := a.swarm.StackExists(stackName)
if !exists {
@ -113,7 +116,6 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
log.Printf("resubscribe: stack deploy failed for %s: %v", result.Username, err)
}
}
log.Printf("resubscribe: %s payment verified, redirecting to dashboard", result.Username)
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
}

View File

@ -41,7 +41,9 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// Reconciliation: ensures LLDAP user exists. Group + stack are handled by /activate.
// Reconciliation backstop: ensures LLDAP user + Stripe ID are set.
// Does NOT send password reset — that's the success page's responsibility
// so it can reliably show the welcome/onboarding page.
func (a *App) onCheckoutCompleted(event stripego.Event) {
var sess stripego.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {

View File

@ -61,6 +61,7 @@
display: block;
}
</style>
{{template "analytics"}}
</head>
<body>
<div class="container">

View File

@ -127,6 +127,7 @@
user-select: all;
}
</style>
{{template "analytics"}}
</head>
<body>
<div class="header">

View File

@ -90,6 +90,7 @@
user-select: all;
}
</style>
{{template "analytics"}}
</head>
<body>
<div class="container">

View File

@ -143,6 +143,7 @@
cursor: not-allowed;
}
</style>
{{template "analytics"}}
</head>
<body>
<div class="container">
@ -177,7 +178,8 @@
</div>
<div class="actions">
<a href="{{.ResetURL}}" class="btn">Resend / Set Password</a>
<button type="button" class="btn" id="reset-btn-new"
onclick="sendReset(this,'{{.Username}}')">Resend Set Password Email</button>
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
</div>
{{else}}
@ -204,11 +206,34 @@
</div>
<div class="actions">
<a href="{{.ResetURL}}" class="btn">Reset Password</a>
<button type="button" class="btn" id="reset-btn-returning"
onclick="sendReset(this,'{{.Username}}')">Resend Set Password Email</button>
<a href="{{.LoginURL}}" class="btn btn-outline">Sign In</a>
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
</div>
{{end}}
</div>
<script>
function sendReset(btn, username) {
btn.disabled = true;
btn.textContent = 'Sending…';
fetch('/resend-reset', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'username=' + encodeURIComponent(username)
}).then(function(r) {
if (r.ok) {
btn.textContent = 'Email sent — check your inbox';
btn.style.background = 'var(--green)';
} else {
btn.textContent = 'Failed — try again';
btn.disabled = false;
}
}).catch(function() {
btn.textContent = 'Failed — try again';
btn.disabled = false;
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
{{define "analytics"}}
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="//metrics.nixc.us/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '12']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo -->
<!-- PostHog -->
<script>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group identify setPersonProperties setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroups onFeatureFlags addFeatureFlagsHandler onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('phc_VP3GGVVmRQx2j1PjOvmiznCLuW7YHp1Hvk218FXPsgR', {
api_host: 'https://eu.i.posthog.com',
defaults: '2026-01-30'
})
</script>
<!-- End PostHog -->
{{end}}

View File

@ -77,7 +77,7 @@ services:
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: login.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: app.bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe$$'"; echo " - '^/success(\\?.*)?$$'"; echo " - '^/webhook/stripe$$'"; echo " - '^/health$$'"; echo " - '^/version$$'"; echo ' - domain: app.bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/dashboard$$'"; echo " - '^/activate$$'"; echo " - '^/portal$$'"; echo " - '^/resubscribe$$'"; echo " - '^/stack-manage$$'"; echo ' - domain:'; echo ' - lldap.bc.a250.ca'; echo ' - whoami.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: "{user}.bc.a250.ca"'; echo ' policy: one_factor'; echo ' - domain: "*.bc.a250.ca"'; echo ' policy: deny'; } > /config/configuration.acl.yml
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: login.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: app.bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe$$'"; echo " - '^/success(\\?.*)?$$'"; echo " - '^/webhook/stripe$$'"; echo " - '^/resend-reset$$'"; echo " - '^/health$$'"; echo " - '^/version$$'"; echo ' - domain: app.bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/dashboard$$'"; echo " - '^/activate$$'"; echo " - '^/portal$$'"; echo " - '^/resubscribe$$'"; echo " - '^/stack-manage$$'"; echo ' - domain:'; echo ' - lldap.bc.a250.ca'; echo ' - whoami.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: "{user}.bc.a250.ca"'; echo ' policy: one_factor'; echo ' - domain: "*.bc.a250.ca"'; echo ' policy: deny'; } > /config/configuration.acl.yml
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml
environment:
X_AUTHELIA_EMAIL: authelia@a250.ca