mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(email): use popup flow for gmail oauth callback (#307)
* feat(email): use popup flow for gmail oauth callback * fix(email): satisfy lint in oauth callback helper
This commit is contained in:
@@ -413,7 +413,7 @@
|
||||
"title": "OAuth2 Authorization",
|
||||
"description": "Authorize this provider to send emails on your behalf. You will be redirected to the provider's login page.",
|
||||
"authorize": "Authorize",
|
||||
"authorizeOpened": "Authorization page opened in a new tab",
|
||||
"authorizeOpened": "Authorization completed",
|
||||
"authorizeFailed": "Failed to start authorization",
|
||||
"status": {
|
||||
"checking": "Checking authorization status...",
|
||||
|
||||
@@ -409,7 +409,7 @@
|
||||
"title": "OAuth2 授权",
|
||||
"description": "授权此提供商以您的名义发送邮件,系统将跳转到提供商登录页面。",
|
||||
"authorize": "授权",
|
||||
"authorizeOpened": "授权页面已在新标签页打开",
|
||||
"authorizeOpened": "授权完成",
|
||||
"authorizeFailed": "启动授权失败",
|
||||
"status": {
|
||||
"checking": "正在检查授权状态...",
|
||||
|
||||
@@ -379,8 +379,35 @@ async function handleAuthorize() {
|
||||
if (error || !data?.auth_url) {
|
||||
throw new Error(t('email.oauth.authorizeFailed'))
|
||||
}
|
||||
window.open(data.auth_url, '_blank', 'noopener,noreferrer')
|
||||
toast.success(t('email.oauth.authorizeOpened'))
|
||||
|
||||
const popup = window.open(data.auth_url, 'email-oauth', 'width=600,height=720')
|
||||
if (!popup) {
|
||||
throw new Error(t('email.oauth.authorizeFailed'))
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
}
|
||||
|
||||
const onMessage = async (event: MessageEvent) => {
|
||||
if (event.data?.type !== 'memoh-email-oauth-callback') return
|
||||
if (event.data?.providerId && event.data.providerId !== curProviderId.value) return
|
||||
|
||||
cleanup()
|
||||
|
||||
if (event.data?.status === 'success') {
|
||||
await fetchOAuthStatus()
|
||||
toast.success(t('email.oauth.authorizeOpened'))
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
reject(new Error(typeof event.data?.error === 'string' && event.data.error ? event.data.error : t('email.oauth.authorizeFailed')))
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('email.oauth.authorizeFailed'))
|
||||
} finally {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -115,10 +116,10 @@ func (h *EmailOAuthHandler) Callback(c echo.Context) error {
|
||||
state := strings.TrimSpace(c.QueryParam("state"))
|
||||
|
||||
if code == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "code is required")
|
||||
return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, "", "error", "code is required")
|
||||
}
|
||||
if state == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "state is required")
|
||||
return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, "", "error", "state is required")
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
@@ -126,16 +127,16 @@ func (h *EmailOAuthHandler) Callback(c echo.Context) error {
|
||||
stored, err := h.tokenStore.GetByState(ctx, state)
|
||||
if err != nil {
|
||||
h.logger.Error("oauth callback: state not found", slog.String("state", state), slog.Any("error", err))
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid or expired state")
|
||||
return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, "", "error", "invalid or expired state")
|
||||
}
|
||||
|
||||
provider, err := h.service.GetProvider(ctx, stored.ProviderID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "provider not found")
|
||||
return renderEmailOAuthCallbackResult(c, http.StatusInternalServerError, stored.ProviderID, "error", "provider not found")
|
||||
}
|
||||
|
||||
if email.ProviderName(provider.Provider) != emailgmail.ProviderName {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "provider does not support OAuth2")
|
||||
return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, stored.ProviderID, "error", "provider does not support OAuth2")
|
||||
}
|
||||
adapter := emailgmail.New(h.logger, h.tokenStore)
|
||||
redirectURI := callbackURLFromState(state)
|
||||
@@ -144,11 +145,11 @@ func (h *EmailOAuthHandler) Callback(c echo.Context) error {
|
||||
}
|
||||
if err := adapter.ExchangeCode(ctx, provider.Config, stored.ProviderID, code, redirectURI); err != nil {
|
||||
h.logger.Error("gmail code exchange failed", slog.Any("error", err))
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "token exchange failed")
|
||||
return renderEmailOAuthCallbackResult(c, http.StatusInternalServerError, stored.ProviderID, "error", "token exchange failed")
|
||||
}
|
||||
|
||||
h.logger.Info("email oauth authorized", slog.String("provider_id", stored.ProviderID), slog.String("provider", provider.Provider))
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "authorized"})
|
||||
return renderEmailOAuthCallbackResult(c, http.StatusOK, stored.ProviderID, "success", "")
|
||||
}
|
||||
|
||||
// Status godoc
|
||||
@@ -328,3 +329,37 @@ func callbackURLFromState(state string) string {
|
||||
}
|
||||
return strings.TrimSpace(string(callbackURL))
|
||||
}
|
||||
|
||||
func renderEmailOAuthCallbackResult(c echo.Context, statusCode int, providerID, status, errorMessage string) error {
|
||||
page := template.Must(template.New("email-oauth-result").Parse(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{if eq .Status "success"}}Gmail OAuth Connected{{else}}Gmail OAuth Failed{{end}}</title>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; padding: 24px;">
|
||||
{{if eq .Status "success"}}
|
||||
<h2>Gmail OAuth connected</h2>
|
||||
<p>You can close this window and return to Memoh.</p>
|
||||
{{else}}
|
||||
<h2>Gmail OAuth failed</h2>
|
||||
<p>{{.Error}}</p>
|
||||
{{end}}
|
||||
<script>
|
||||
window.opener?.postMessage({
|
||||
type: "memoh-email-oauth-callback",
|
||||
status: "{{.Status}}",
|
||||
providerId: "{{.ProviderID}}",
|
||||
error: "{{.Error}}"
|
||||
}, "*");
|
||||
setTimeout(() => window.close(), 300);
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
return c.HTML(statusCode, executeHTMLTemplate(page, map[string]string{
|
||||
"ProviderID": providerID,
|
||||
"Status": status,
|
||||
"Error": errorMessage,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user