Skip to content

Commit

Permalink
feat: enable email i18n
Browse files Browse the repository at this point in the history
  • Loading branch information
lfleischmann committed Jan 9, 2025
1 parent 287291f commit fc05651
Show file tree
Hide file tree
Showing 11 changed files with 70 additions and 8 deletions.
5 changes: 3 additions & 2 deletions backend/dto/webhook/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package webhook
type EmailSend struct {
Subject string `json:"subject"` // subject
BodyPlain string `json:"body_plain"` // used for string templates
Body string `json:"body,omitempty"` // used for html templates
Body string `json:"body,omitempty"` // used for HTML templates
ToEmailAddress string `json:"to_email_address"`
DeliveredByHanko bool `json:"delivered_by_hanko"`
AcceptLanguage string `json:"accept_language"` // accept_language header from http request
AcceptLanguage string `json:"accept_language"` // Deprecated. Accept-Language header from HTTP request
Language string `json:"language"` // X-Language header from HTTP request
Type EmailType `json:"type"` // type of the email, currently only "passcode", but other could be added later

Data interface{} `json:"data"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (a ReSendPasscode) Execute(c flowpilot.ExecutionContext) error {
sendParams := services.SendPasscodeParams{
Template: c.Stash().Get(shared.StashPathPasscodeTemplate).String(),
EmailAddress: c.Stash().Get(shared.StashPathEmail).String(),
Language: deps.HttpContext.Request().Header.Get("Accept-Language"),
Language: deps.HttpContext.Request().Header.Get("X-Language"),
}
passcodeResult, err := deps.PasscodeService.SendPasscode(deps.Tx, sendParams)
if err != nil {
Expand All @@ -70,6 +70,7 @@ func (a ReSendPasscode) Execute(c flowpilot.ExecutionContext) error {
ToEmailAddress: sendParams.EmailAddress,
DeliveredByHanko: deps.Cfg.EmailDelivery.Enabled,
AcceptLanguage: sendParams.Language,
Language: sendParams.Language,
Type: webhook.EmailTypePasscode,
Data: webhook.PasscodeData{
ServiceName: deps.Cfg.Service.Name,
Expand Down
3 changes: 2 additions & 1 deletion backend/flow_api/flow/credential_usage/hook_send_passcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (h SendPasscode) Execute(c flowpilot.HookExecutionContext) error {
sendParams := services.SendPasscodeParams{
Template: c.Stash().Get(shared.StashPathPasscodeTemplate).String(),
EmailAddress: c.Stash().Get(shared.StashPathEmail).String(),
Language: deps.HttpContext.Request().Header.Get("Accept-Language"),
Language: deps.HttpContext.Request().Header.Get("X-Language"),
}

passcodeResult, err := deps.PasscodeService.SendPasscode(deps.Tx, sendParams)
Expand All @@ -91,6 +91,7 @@ func (h SendPasscode) Execute(c flowpilot.HookExecutionContext) error {
ToEmailAddress: sendParams.EmailAddress,
DeliveredByHanko: deps.Cfg.EmailDelivery.Enabled,
AcceptLanguage: sendParams.Language,
Language: sendParams.Language,
Type: webhook.EmailTypePasscode,
Data: webhook.PasscodeData{
ServiceName: deps.Cfg.Service.Name,
Expand Down
1 change: 1 addition & 0 deletions backend/handler/passcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ func (h *PasscodeHandler) Init(c echo.Context) error {
ToEmailAddress: email.Address,
DeliveredByHanko: true,
AcceptLanguage: lang,
Language: lang,
Type: webhook.EmailTypePasscode,
Data: webhook.PasscodeData{
ServiceName: h.cfg.Service.Name,
Expand Down
8 changes: 8 additions & 0 deletions frontend/elements/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ Translations are currently available for the following languages:
- "de" - German
- "en" - English
- "fr" - French
- "it" - Italian
- "ptBR" - Brazilian Portuguese
- "zh" - Simplified Chinese

Expand Down Expand Up @@ -706,6 +707,13 @@ Markup:
<hanko-auth lang="symbols"></hanko-auth>
```

### Translation of outgoing Hanko emails

If you use Hanko Elements the language supplied to the `lang` attribute of any of the components is also used to convey
to the Hanko API the language to use for outgoing emails. If you have disabled email delivery through Hanko and
configured a webhook for the `email.send` event, the value for the `lang` attribute is reflected in the JWT payload of
the token contained in the webhook request in the `language` claim.

## Experimental Features

### Conditional Mediation / Autofill assisted Requests
Expand Down
10 changes: 7 additions & 3 deletions frontend/elements/src/components/wrapper/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ interface Props extends h.JSX.HTMLAttributes<HTMLElement> {
}

const Container = forwardRef<HTMLElement>((props: Props, ref) => {
const { lang } = useContext(AppContext);
const { lang, hanko, setHanko } = useContext(AppContext);
const { setLang } = useContext(TranslateContext);

useEffect(() => {
setLang(lang);
}, [lang, setLang]);
setLang(lang.replace(/[-]/, ""));
setHanko((hanko) => {
hanko.setLang(lang);
return hanko;
});
}, [hanko, lang, setHanko, setLang]);

return (
<section part={"container"} className={styles.container} ref={ref}>
Expand Down
8 changes: 8 additions & 0 deletions frontend/elements/src/contexts/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ interface UIState {

interface Context {
hanko: Hanko;
setHanko: StateUpdater<Hanko>;
page: h.JSX.Element;
setPage: StateUpdater<h.JSX.Element>;
init: (compName: ComponentName) => void;
Expand Down Expand Up @@ -179,6 +180,11 @@ const AppProvider = ({
fallbackLanguage,
} = globalOptions;

// Without this, the initial "lang" attribute value sometimes appears to not
// be set properly. This results in a wrong X-Language header value being sent
// to the API and hence in outgoing emails translated in the wrong language.
hanko.setLang(lang?.toString() || fallbackLanguage);

const ref = useRef<HTMLElement>(null);

const storageKeyLastLogin = useMemo(
Expand All @@ -201,6 +207,7 @@ const AppProvider = ({

const initComponent = useMemo(() => <InitPage />, []);
const [page, setPage] = useState<h.JSX.Element>(initComponent);
const [, setHanko] = useState<Hanko>(hanko);
const [lastLogin, setLastLogin] = useState<LastLogin>();
const [uiState, setUIState] = useState<UIState>({
email: prefilledEmail,
Expand Down Expand Up @@ -598,6 +605,7 @@ const AppProvider = ({
setSucceededAction,
uiState,
hanko,
setHanko,
lang: lang?.toString() || fallbackLanguage,
prefilledEmail,
prefilledUsername,
Expand Down
2 changes: 1 addition & 1 deletion frontend/elements/src/example.html
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
<option value="en" selected>English</option>
<option value="fr">French</option>
<option value="it">Italian</option>
<option value="ptBR">Brazilian Portuguese</option>
<option value="pt-BR">Brazilian Portuguese</option>
<option value="zh">Chinese</option>
</select>
</nav>
Expand Down
8 changes: 8 additions & 0 deletions frontend/frontend-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ hanko.onUserDeleted(() => {

Please Take a look into the [docs](https://teamhanko.github.io/hanko/jsdoc/hanko-frontend-sdk/) for more details.

### Translation of outgoing emails

If you use the main `Hanko` client provided by the Frontend SDK, you can use the `lang` parameter in the options when
instantiating the client to configure the language that is used to convey to the Hanko API the
language to use for outgoing emails. If you have disabled email delivery through Hanko and configured a webhook for the
`email.send` event, the value for the `lang` parameter is reflected in the JWT payload of the token contained in the
webhook request in the "Language" claim.

## Bugs

Found a bug? Please report on our [GitHub](https://github.com/teamhanko/hanko/issues) page.
Expand Down
23 changes: 23 additions & 0 deletions frontend/frontend-sdk/src/Hanko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@ import { SessionClient } from "./lib/client/SessionClient";
* @property {string=} cookieDomain - The domain where the cookie set from the SDK is available. Defaults to the domain of the page where the cookie was created.
* @property {string=} cookieSameSite - Specify whether/when cookies are sent with cross-site requests. Defaults to "lax".
* @property {string=} localStorageKey - The prefix / name of the local storage keys. Defaults to "hanko"
* @property {string=} lang - Used to convey the preferred language to the API, e.g. for translating outgoing emails.
* It is transmitted to the API in a custom header (X-Language).
* Should match one of the supported languages ("bn", "de", "en", "fr", "it, "pt-BR", "zh")
* if email delivery by Hanko is enabled. If email delivery by Hanko is disabled and the
* relying party configures a webhook for the "email.send" event, then the set language is
* reflected in the payload of the token contained in the webhook request.
*/
export interface HankoOptions {
timeout?: number;
cookieName?: string;
cookieDomain?: string;
cookieSameSite?: CookieSameSite;
localStorageKey?: string;
lang?: string;
}

/**
Expand Down Expand Up @@ -70,6 +77,9 @@ class Hanko extends Listener {
if (options?.cookieSameSite !== undefined) {
opts.cookieSameSite = options.cookieSameSite;
}
if (options?.lang !== undefined) {
opts.lang = options.lang;
}

this.api = api;
/**
Expand Down Expand Up @@ -118,6 +128,18 @@ class Hanko extends Listener {
*/
this.flow = new Flow(api, opts);
}

/**
* Sets the preferred language on the underlying sub-clients. The clients'
* base HttpClient uses this language to transmit an X-Language header to the
* API which is then used to e.g. translate outgoing emails.
*
* @public
* @param lang {string} - The preferred language to convey to the API.
*/
setLang(lang: string) {
this.flow.client.lang = lang;
}
}

// eslint-disable-next-line require-jsdoc
Expand All @@ -127,6 +149,7 @@ export interface InternalOptions {
cookieDomain?: string;
cookieSameSite?: CookieSameSite;
localStorageKey: string;
lang?: string;
}

export { Hanko };
7 changes: 7 additions & 0 deletions frontend/frontend-sdk/src/lib/client/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,15 @@ class Response {
* @property {string} cookieName - The name of the session cookie set from the SDK.
* @property {string=} cookieDomain - The domain where cookie set from the SDK is available. Defaults to the domain of the page where the cookie was created.
* @property {string} localStorageKey - The prefix / name of the local storage keys.
* @property {string} lang - The language used by the client(s) to convey to the Hanko API the language to use -
* e.g. for translating outgoing emails - in a custom header (X-Language).
*/
export interface HttpClientOptions {
timeout: number;
cookieName: string;
cookieDomain?: string;
localStorageKey: string;
lang?: string;
}

/**
Expand All @@ -143,6 +146,7 @@ class HttpClient {
sessionState: SessionState;
dispatcher: Dispatcher;
cookie: Cookie;
lang: string;

// eslint-disable-next-line require-jsdoc
constructor(api: string, options: HttpClientOptions) {
Expand All @@ -151,6 +155,7 @@ class HttpClient {
this.sessionState = new SessionState({ ...options });
this.dispatcher = new Dispatcher({ ...options });
this.cookie = new Cookie({ ...options });
this.lang = options.lang;
}

// eslint-disable-next-line require-jsdoc
Expand All @@ -159,11 +164,13 @@ class HttpClient {
const url = this.api + path;
const timeout = this.timeout;
const bearerToken = this.cookie.getAuthCookie();
const lang = this.lang;

return new Promise<Response>(function (resolve, reject) {
xhr.open(options.method, url, true);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader("X-Language", lang);

if (bearerToken) {
xhr.setRequestHeader("Authorization", `Bearer ${bearerToken}`);
Expand Down

0 comments on commit fc05651

Please sign in to comment.