mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-21 18:39:05 +02:00
Merge branch 'main' into actors/francis-addr
This commit is contained in:
+187
-11
@@ -1,27 +1,180 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: Version bump (auto uses conventional commits)
|
||||
type: choice
|
||||
default: auto
|
||||
options:
|
||||
- auto
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
release:
|
||||
prepare:
|
||||
name: Prepare release
|
||||
runs-on: depot-ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
commit: ${{ steps.commit.outputs.commit }}
|
||||
|
||||
steps:
|
||||
- name: Require the main branch
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != refs/heads/main ]]; then
|
||||
echo "::error::Releases must be triggered from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout release source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup git-cliff
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: git-cliff@2.14.2
|
||||
|
||||
- name: Calculate next version
|
||||
id: version
|
||||
env:
|
||||
BUMP: ${{ inputs.bump }}
|
||||
run: |
|
||||
version=$(git cliff --bumped-version --unreleased --offline --bump "$BUMP")
|
||||
version=${version#v}
|
||||
if [[ "$version" == "$(cat .version)" ]]; then
|
||||
echo "No commits requiring a version bump; no release created." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$version" >> "$GITHUB_OUTPUT"
|
||||
echo "Preparing release v$version from $GITHUB_SHA." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.version.outputs.tag != ''
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.version.outputs.tag != ''
|
||||
uses: actions/setup-node@v6.5.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version.outputs.tag != ''
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Update version and changelog
|
||||
if: steps.version.outputs.tag != ''
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.tag }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
printf '%s\n' "$VERSION" > .version
|
||||
jq --arg version "$VERSION" '.version = $version' frontend/package.json > "$RUNNER_TEMP/package.json"
|
||||
mv "$RUNNER_TEMP/package.json" frontend/package.json
|
||||
pnpm --dir frontend exec prettier --write package.json
|
||||
git cliff --prepend CHANGELOG.md --tag "$RELEASE_TAG" --unreleased
|
||||
|
||||
- name: Create bot app token
|
||||
if: steps.version.outputs.tag != ''
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
client-id: ${{ vars.BOT_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Commit and tag release
|
||||
if: steps.version.outputs.tag != ''
|
||||
id: commit
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.tag }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
run: |
|
||||
# Attribute the release commit to the app and authenticate the push with its installation token
|
||||
bot_name="$APP_SLUG[bot]"
|
||||
bot_id=$(gh api "users/$bot_name" --jq .id)
|
||||
git config user.name "$bot_name"
|
||||
git config user.email "$bot_id+$bot_name@users.noreply.github.com"
|
||||
gh auth setup-git
|
||||
|
||||
git add .version frontend/package.json CHANGELOG.md
|
||||
git commit -m "release: $VERSION"
|
||||
git tag "$RELEASE_TAG"
|
||||
|
||||
# Publish both refs together so a concurrent main update cannot leave an orphaned release tag
|
||||
git push --atomic origin HEAD:refs/heads/main "refs/tags/$RELEASE_TAG"
|
||||
echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build and attest
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag != ''
|
||||
runs-on: depot-ubuntu-24.04-16
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.commit }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create bot app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
client-id: ${{ vars.BOT_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Create draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
# Reuse the draft when retrying a failed build
|
||||
if is_draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then
|
||||
if [[ "$is_draft" != true ]]; then
|
||||
echo "::error::Release $RELEASE_TAG is already published."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
awk '/^## v[0-9]/ { if (found) exit; found=1; next } found' CHANGELOG.md > "$RUNNER_TEMP/release-notes.md"
|
||||
gh release create "$RELEASE_TAG" --verify-tag --title "$RELEASE_TAG" --notes-file "$RUNNER_TEMP/release-notes.md" --draft
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -39,6 +192,11 @@ jobs:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Setup Syft
|
||||
uses: anchore/sbom-action/download-syft@v0.24.2
|
||||
with:
|
||||
syft-version: v1.52.0
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
@@ -85,7 +243,25 @@ jobs:
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
publish:
|
||||
name: Publish release
|
||||
needs: [prepare, build]
|
||||
runs-on: depot-ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Create bot app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
client-id: ${{ vars.BOT_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
gh release edit "$RELEASE_TAG" --draft=false
|
||||
echo "Published [$RELEASE_TAG](https://github.com/$GH_REPO/releases/tag/$RELEASE_TAG)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -9,6 +9,9 @@ before:
|
||||
hooks:
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm -C frontend build
|
||||
- mkdir -p .tmp
|
||||
- mv backend/frontend/dist/cyclonedx/frontend.cdx.json .tmp/frontend.cdx.json
|
||||
- rm -r backend/frontend/dist/cyclonedx
|
||||
|
||||
builds:
|
||||
- id: pocket-id
|
||||
@@ -91,6 +94,23 @@ archives:
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
sboms:
|
||||
- id: binaries
|
||||
artifacts: binary
|
||||
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
|
||||
documents:
|
||||
- "pocket-id_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ targetVariant . }}.sbom.spdx.json"
|
||||
cmd: sh
|
||||
args:
|
||||
- ../scripts/development/generate-binary-sbom.sh
|
||||
- $artifact
|
||||
- $document
|
||||
- "pocket-id_{{ .Os }}_{{ .Arch }}{{ targetVariant . }}"
|
||||
- "{{ .Version }}"
|
||||
env:
|
||||
- SYFT_CHECK_FOR_APP_UPDATE=false
|
||||
- SYFT_CACHE_DIR=
|
||||
|
||||
docker_digest:
|
||||
name_template: digests.txt
|
||||
|
||||
@@ -124,6 +144,7 @@ dockers_v2:
|
||||
sbom: true
|
||||
extra_files:
|
||||
- scripts/docker
|
||||
- .tmp/frontend.cdx.json
|
||||
|
||||
- id: pocket-id-distroless
|
||||
ids:
|
||||
@@ -152,6 +173,8 @@ dockers_v2:
|
||||
"org.opencontainers.image.title": "Pocket ID"
|
||||
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
|
||||
sbom: true
|
||||
extra_files:
|
||||
- .tmp/frontend.cdx.json
|
||||
|
||||
- id: pocket-id-next
|
||||
ids:
|
||||
@@ -179,6 +202,7 @@ dockers_v2:
|
||||
sbom: true
|
||||
extra_files:
|
||||
- scripts/docker
|
||||
- .tmp/frontend.cdx.json
|
||||
|
||||
- id: pocket-id-next-distroless
|
||||
ids:
|
||||
@@ -204,6 +228,8 @@ dockers_v2:
|
||||
"org.opencontainers.image.title": "Pocket ID"
|
||||
disable: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
|
||||
sbom: true
|
||||
extra_files:
|
||||
- .tmp/frontend.cdx.json
|
||||
|
||||
notarize:
|
||||
macos:
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
## v2.16.0
|
||||
|
||||
### Features
|
||||
|
||||
- update the design of the email templates ([3249b0d](https://github.com/pocket-id/pocket-id/commit/3249b0dc8c00220b74e1008f94812cc02f6313c1) by @stonith404)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- loading indicator not visible ([4ba2899](https://github.com/pocket-id/pocket-id/commit/4ba28992ff496caa20b6abe6f614eeb1fa777469) by @stonith404)
|
||||
- login with Yubikey not working in some Safari browsers ([4858db3](https://github.com/pocket-id/pocket-id/commit/4858db3bb4cf6619cad8682da61f0e793f24874c) by @stonith404)
|
||||
|
||||
### Other
|
||||
|
||||
- upgrade git cliff version ([98f9e39](https://github.com/pocket-id/pocket-id/commit/98f9e39785ae913d65ab3ad3523feaf907d3686e) by @stonith404)
|
||||
- update francis to rc7 ([\#1769](https://github.com/pocket-id/pocket-id/pull/1769) by @ItalyPaleAle)
|
||||
- include frontend dependencies in SBOM ([5a1c6f0](https://github.com/pocket-id/pocket-id/commit/5a1c6f0547f779175107d31ea13d4f24000a1a65) by @stonith404)
|
||||
- include SBOMs for binaries ([8fe53ed](https://github.com/pocket-id/pocket-id/commit/8fe53ed42c522717c8c69612cc700e195b8ab2ea) by @stonith404)
|
||||
- replace local release script with action ([ba246e6](https://github.com/pocket-id/pocket-id/commit/ba246e61465378bc11571fcc77ca01c936fda174) by @stonith404)
|
||||
|
||||
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.15.0...v2.16.0
|
||||
## v2.15.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -36,6 +36,7 @@ type template[V any] struct {
|
||||
type templateData[V any] struct {
|
||||
AppName string
|
||||
LogoURL string
|
||||
AppURL string
|
||||
Data *V
|
||||
}
|
||||
|
||||
@@ -138,6 +139,7 @@ func send[V any](ctx context.Context, module *Module, dbConfig *appconfig.AppCon
|
||||
templateData := &templateData[V]{
|
||||
AppName: dbConfig.AppName.String(),
|
||||
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
|
||||
AppURL: common.EnvConfig.AppURL,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestModuleSendsEveryEmailType(t *testing.T) {
|
||||
{
|
||||
name: "email verification",
|
||||
subject: "Verify your Pocket ID Test email address",
|
||||
bodyContains: []string{"EMAIL VERIFICATION", "Hello Test User", "https://id.example.test/verify-token"},
|
||||
bodyContains: []string{"VERIFY YOUR EMAIL ADDRESS", "Hello Test User", "https://id.example.test/verify-token"},
|
||||
send: func(ctx context.Context, config *appconfig.AppConfigModel) error {
|
||||
return module.SendEmailVerification(ctx, config, user.FullName(), userEmail, "https://id.example.test/verify-token")
|
||||
},
|
||||
|
||||
@@ -194,6 +194,22 @@ func writeErrorResponse(c *gin.Context, classified classifiedError, requestID st
|
||||
|
||||
func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) {
|
||||
if classified.status < http.StatusInternalServerError {
|
||||
cause := errors.Unwrap(err)
|
||||
if cause == nil {
|
||||
return
|
||||
}
|
||||
|
||||
slog.DebugContext(c.Request.Context(), "Request rejected",
|
||||
slog.String("error_code", string(classified.code)),
|
||||
slog.String("error_type", errorTypeName(err)),
|
||||
slog.String("cause_type", errorTypeName(cause)),
|
||||
slog.Int("http_status", classified.status),
|
||||
slog.String("request_id", requestID),
|
||||
slog.String("http_method", c.Request.Method),
|
||||
slog.String("http_path", c.Request.URL.Path),
|
||||
slog.Any("error", err),
|
||||
slog.Any("cause", cause),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
|
||||
Extensions: storedSession.Extensions,
|
||||
CredParams: storedSession.CredentialParams,
|
||||
}
|
||||
discardUnrequestedFalseAppIDOutput(session.Extensions, credentialAssertionData)
|
||||
|
||||
var user *model.User
|
||||
_, err := s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (gowebauthn.User, error) {
|
||||
@@ -535,6 +536,7 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
|
||||
Extensions: storedSession.Extensions,
|
||||
CredParams: storedSession.CredentialParams,
|
||||
}
|
||||
discardUnrequestedFalseAppIDOutput(session.Extensions, credentialAssertionData)
|
||||
|
||||
// Validate the credential assertion
|
||||
var user *model.User
|
||||
@@ -589,6 +591,21 @@ func classifyPasskeyError(err error, fallback func(error) *apperror.Error) *appe
|
||||
return fallback(err)
|
||||
}
|
||||
|
||||
func discardUnrequestedFalseAppIDOutput(session protocol.SessionExtensions, credential *protocol.ParsedCredentialAssertionData) {
|
||||
if credential == nil || credential.ClientExtensionResults.AppID == nil || *credential.ClientExtensionResults.AppID {
|
||||
return
|
||||
}
|
||||
|
||||
for _, requested := range session.Requested {
|
||||
if requested == protocol.ExtensionAppID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Safari reports appid=false for security keys even when the relying party did not request the legacy extension
|
||||
credential.ClientExtensionResults.AppID = nil
|
||||
}
|
||||
|
||||
func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) {
|
||||
hashedToken := utils.CreateSha256Hash(token)
|
||||
var reauthToken ReauthenticationToken
|
||||
|
||||
@@ -263,6 +263,66 @@ func TestClassifyPasskeyErrorPreservesStructuredLookupFailure(t *testing.T) {
|
||||
require.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestDiscardUnrequestedFalseAppIDOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested []string
|
||||
appID bool
|
||||
extra map[string]any
|
||||
wantAppID *bool
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "unrequested false appid is discarded",
|
||||
appID: false,
|
||||
wantAppID: nil,
|
||||
},
|
||||
{
|
||||
name: "requested false appid is preserved",
|
||||
requested: []string{protocol.ExtensionAppID},
|
||||
appID: false,
|
||||
wantAppID: new(false),
|
||||
},
|
||||
{
|
||||
name: "unrequested true appid is rejected",
|
||||
appID: true,
|
||||
wantAppID: new(true),
|
||||
wantError: "appid",
|
||||
},
|
||||
{
|
||||
name: "other unsolicited output is rejected",
|
||||
appID: false,
|
||||
extra: map[string]any{"example": true},
|
||||
wantAppID: nil,
|
||||
wantError: "example",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
credential := &protocol.ParsedCredentialAssertionData{
|
||||
ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: protocol.AuthenticationExtensionsClientOutputs{
|
||||
AppID: new(tc.appID),
|
||||
Extra: tc.extra,
|
||||
},
|
||||
},
|
||||
}
|
||||
session := protocol.SessionExtensions{Requested: tc.requested}
|
||||
|
||||
discardUnrequestedFalseAppIDOutput(session, credential)
|
||||
|
||||
assert.Equal(t, tc.wantAppID, credential.ClientExtensionResults.AppID)
|
||||
err := credential.ClientExtensionResults.Verify(session, protocol.AssertCeremony, protocol.UnsolicitedOutputPolicyReject)
|
||||
if tc.wantError == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnManagementOperationsReturnSpecificNotFoundErrors(t *testing.T) {
|
||||
service, err := newService(Dependencies{
|
||||
DB: testutils.NewDatabaseForTest(t),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,11 +1,7 @@
|
||||
{{define "root"}}{{.AppName}}
|
||||
|
||||
|
||||
API KEY EXPIRING SOON
|
||||
|
||||
Warning
|
||||
{{define "root"}}API KEY EXPIRING SOON
|
||||
|
||||
Hello {{.Data.Name}},
|
||||
This is a reminder that your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||
|
||||
Please generate a new API key if you need continued access.{{end}}
|
||||
Your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. Anything that uses this key will stop working once it expires.
|
||||
|
||||
To keep access, create a new API key in your {{.AppName}} account settings before then.{{end}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,10 +1,10 @@
|
||||
{{define "root"}}{{.AppName}}
|
||||
|
||||
|
||||
EMAIL VERIFICATION
|
||||
{{define "root"}}VERIFY YOUR EMAIL ADDRESS
|
||||
|
||||
Hello {{.Data.UserFullName}},
|
||||
Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours.
|
||||
|
||||
Click the button below to confirm the email address for your {{.AppName}} account. This link expires in 24 hours.
|
||||
|
||||
Verify {{.Data.VerificationLink}}{{end}}
|
||||
Verify email address {{.Data.VerificationLink}}
|
||||
|
||||
Or if you don't like clicking buttons, open this link:
|
||||
{{.Data.VerificationLink}}{{end}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,26 +1,17 @@
|
||||
{{define "root"}}{{.AppName}}
|
||||
{{define "root"}}NEW SIGN-IN DETECTED
|
||||
|
||||
Your {{.AppName}} account was recently accessed from a new IP address or browser. If this was you, no further action is needed.
|
||||
|
||||
NEW SIGN-IN DETECTED
|
||||
|
||||
Warning
|
||||
|
||||
Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
|
||||
|
||||
DETAILS
|
||||
|
||||
Approximate Location
|
||||
|
||||
Approximate location
|
||||
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
|
||||
|
||||
IP Address
|
||||
|
||||
IP address
|
||||
{{.Data.IPAddress}}
|
||||
|
||||
Device
|
||||
|
||||
{{.Data.Device}}
|
||||
|
||||
Sign-In Time
|
||||
Time
|
||||
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}
|
||||
|
||||
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}{{end}}
|
||||
If you don't recognize this activity, review the passkeys in your {{.AppName}} account settings and remove any you don't recognize.{{end}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,11 +1,9 @@
|
||||
{{define "root"}}{{.AppName}}
|
||||
{{define "root"}}YOUR LOGIN CODE
|
||||
|
||||
Use the code below to sign in to {{.AppName}}. It expires in {{.Data.ExpirationString}}.
|
||||
|
||||
YOUR LOGIN CODE
|
||||
{{.Data.Code}}
|
||||
|
||||
Click the button below to sign in to {{.AppName}} with a login code.
|
||||
Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
|
||||
Sign in {{.Data.LoginLinkWithCode}}
|
||||
|
||||
This code expires in {{.Data.ExpirationString}}.
|
||||
|
||||
Sign In {{.Data.LoginLinkWithCode}}{{end}}
|
||||
Or open {{.Data.LoginLink}} and enter the code manually.{{end}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,3 @@
|
||||
{{define "root"}}{{.AppName}}
|
||||
|
||||
|
||||
TEST EMAIL
|
||||
{{define "root"}}TEST EMAIL
|
||||
|
||||
Your email setup is working correctly!{{end}}
|
||||
@@ -10,6 +10,7 @@ ARG TARGETVARIANT
|
||||
WORKDIR /app
|
||||
|
||||
COPY linux/${TARGETARCH}/${TARGETVARIANT}/pocket-id /app/pocket-id
|
||||
COPY ./.tmp/frontend.cdx.json /usr/share/doc/pocket-id/frontend.cdx.json
|
||||
|
||||
EXPOSE 1411
|
||||
ENV APP_ENV=production
|
||||
|
||||
@@ -13,6 +13,7 @@ RUN apk add --no-cache su-exec
|
||||
|
||||
COPY linux/${TARGETARCH}/${TARGETVARIANT}/pocket-id /app/pocket-id
|
||||
COPY ./scripts/docker /app/docker
|
||||
COPY ./.tmp/frontend.cdx.json /usr/share/doc/pocket-id/frontend.cdx.json
|
||||
|
||||
EXPOSE 1411
|
||||
ENV APP_ENV=production
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from "@react-email/components";
|
||||
import { render } from "react-email";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
@@ -17,9 +17,10 @@ async function buildTemplateFile(
|
||||
templateName: string,
|
||||
isPlainText: boolean
|
||||
) {
|
||||
const rendered = await render(Component(Component.TemplateProps), {
|
||||
plainText: isPlainText,
|
||||
});
|
||||
const rendered = await render(
|
||||
Component(Component.TemplateProps),
|
||||
isPlainText ? { plainText: true } : {},
|
||||
);
|
||||
|
||||
// Normalize quotes
|
||||
const normalized = rendered.replace(/"/g, '"');
|
||||
|
||||
@@ -5,77 +5,115 @@ import {
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
} from "react-email";
|
||||
import type { SharedProps } from "../props";
|
||||
import { colors, fonts, radius } from "./theme";
|
||||
|
||||
interface BaseTemplateProps {
|
||||
logoURL?: string;
|
||||
appName: string;
|
||||
interface BaseTemplateProps extends SharedProps {
|
||||
preview?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BaseTemplate = ({
|
||||
logoURL,
|
||||
appName,
|
||||
appURL,
|
||||
preview,
|
||||
children,
|
||||
}: BaseTemplateProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={mainStyle}>
|
||||
<Container style={{ width: "500px", margin: "0 auto" }}>
|
||||
<Section>
|
||||
<Row
|
||||
align="left"
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<Column style={{ width: "50px" }}>
|
||||
}: BaseTemplateProps) => (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<style dangerouslySetInnerHTML={{ __html: fontFaceCss(appURL) }} />
|
||||
</Head>
|
||||
{preview && <Preview>{preview}</Preview>}
|
||||
<Body style={bodyStyle}>
|
||||
<Container style={containerStyle}>
|
||||
<Section style={headerStyle} data-skip-in-text="true">
|
||||
<Row>
|
||||
<Column style={logoColumnStyle}>
|
||||
<Link href={appURL}>
|
||||
<Img
|
||||
src={logoURL}
|
||||
width="32"
|
||||
height="32"
|
||||
width="28"
|
||||
height="28"
|
||||
alt={appName}
|
||||
style={logoStyle}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={titleStyle}>{appName}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<div style={content}>{children}</div>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
</Link>
|
||||
</Column>
|
||||
<Column>
|
||||
<Link href={appURL} style={appNameStyle}>
|
||||
{appName}
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Row>
|
||||
<Column style={cardStyle}>{children}</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
// The heading font is self-hosted by the app, so it loads from the same origin as the logo and works without third-party requests
|
||||
// Clients without web font support fall back to the serif stack declared on the headings
|
||||
const fontFaceCss = (appURL: string) =>
|
||||
`@font-face{font-family:'Gloock';font-style:normal;font-weight:400;mso-font-alt:'Georgia';src:url(${appURL}/fonts/Gloock-Regular.woff) format('woff');}`;
|
||||
|
||||
const bodyStyle = {
|
||||
margin: 0,
|
||||
padding: "32px 16px",
|
||||
backgroundColor: colors.background,
|
||||
fontFamily: fonts.sans,
|
||||
color: colors.text,
|
||||
};
|
||||
|
||||
const mainStyle = {
|
||||
padding: "50px",
|
||||
backgroundColor: "#FBFBFB",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
const containerStyle = {
|
||||
width: "100%",
|
||||
maxWidth: "480px",
|
||||
margin: "0 auto",
|
||||
};
|
||||
|
||||
const logoStyle = {
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
const headerStyle = {
|
||||
marginBottom: "20px",
|
||||
};
|
||||
|
||||
const logoColumnStyle = {
|
||||
width: "36px",
|
||||
verticalAlign: "middle",
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontSize: "23px",
|
||||
fontWeight: "bold",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
const logoStyle = {
|
||||
display: "block",
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "6px",
|
||||
};
|
||||
|
||||
const content = {
|
||||
backgroundColor: "white",
|
||||
padding: "24px",
|
||||
borderRadius: "10px",
|
||||
boxShadow: "0 1px 4px 0px rgba(0, 0, 0, 0.1)",
|
||||
const appNameStyle = {
|
||||
fontFamily: fonts.serif,
|
||||
fontSize: "20px",
|
||||
lineHeight: "28px",
|
||||
color: colors.foreground,
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: colors.card,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: radius.card,
|
||||
padding: "32px",
|
||||
textAlign: "left" as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { Button as EmailButton } from "@react-email/components";
|
||||
import { Button as EmailButton, Section } from "react-email";
|
||||
import { colors, fonts, radius } from "./theme";
|
||||
|
||||
interface ButtonProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Button = ({ href, children, style = {} }: ButtonProps) => {
|
||||
const buttonStyle = {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 24px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "15px",
|
||||
fontWeight: "500",
|
||||
cursor: "pointer",
|
||||
marginTop: "10px",
|
||||
...style,
|
||||
};
|
||||
export const Button = ({ href, children }: ButtonProps) => (
|
||||
<Section style={containerStyle}>
|
||||
<EmailButton href={href} style={buttonStyle}>
|
||||
{children}
|
||||
</EmailButton>
|
||||
</Section>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={buttonContainer}>
|
||||
<EmailButton style={buttonStyle} href={href}>
|
||||
{children}
|
||||
</EmailButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
const containerStyle = {
|
||||
margin: "24px 0 8px 0",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
display: "inline-block",
|
||||
padding: "12px 28px",
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryForeground,
|
||||
borderRadius: radius.pill,
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { Column, Heading, Row, Text } from "@react-email/components";
|
||||
import { Heading } from "react-email";
|
||||
import { colors, fonts } from "./theme";
|
||||
|
||||
export default function CardHeader({
|
||||
title,
|
||||
warning,
|
||||
}: {
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
warning?: boolean;
|
||||
}) {
|
||||
}
|
||||
|
||||
export default function CardHeader({ title }: CardHeaderProps) {
|
||||
return (
|
||||
<Row>
|
||||
<Column>
|
||||
<Heading as="h1" style={titleStyle}>
|
||||
{title}
|
||||
</Heading>
|
||||
</Column>
|
||||
<Column align="right">
|
||||
{warning && <Text style={warningStyle}>Warning</Text>}
|
||||
</Column>
|
||||
</Row>
|
||||
<Heading as="h1" style={titleStyle}>
|
||||
{title}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
const titleStyle = {
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold" as const,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const warningStyle = {
|
||||
backgroundColor: "#ffd966",
|
||||
color: "#7f6000",
|
||||
padding: "1px 12px",
|
||||
borderRadius: "50px",
|
||||
fontSize: "12px",
|
||||
display: "inline-block",
|
||||
margin: 0,
|
||||
margin: "0 0 16px 0",
|
||||
fontFamily: fonts.serif,
|
||||
fontSize: "26px",
|
||||
lineHeight: "32px",
|
||||
fontWeight: 400,
|
||||
letterSpacing: "-0.01em",
|
||||
color: colors.foreground,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Column, Row, Section, Text } from "react-email";
|
||||
import { colors, fonts, radius } from "./theme";
|
||||
|
||||
interface CodeBoxProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const CodeBox = ({ code }: CodeBoxProps) => (
|
||||
<Section style={boxStyle}>
|
||||
<Row>
|
||||
<Column style={cellStyle}>
|
||||
<Text style={codeStyle}>{code}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
);
|
||||
|
||||
const boxStyle = {
|
||||
margin: "8px 0 0 0",
|
||||
backgroundColor: colors.muted,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: radius.box,
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
padding: "20px 16px",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const codeStyle = {
|
||||
margin: 0,
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: "32px",
|
||||
lineHeight: "40px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "8px",
|
||||
paddingLeft: "8px",
|
||||
color: colors.foreground,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Column, Row, Section } from "react-email";
|
||||
import { colors, radius } from "./theme";
|
||||
|
||||
interface DetailsListProps {
|
||||
items: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
// A key-value list inside a muted box, used for sign-in details and similar metadata
|
||||
// Each value sits below its label so long values never fight the label for width on narrow screens
|
||||
// Every item is its own Row so the plain-text build separates entries with a blank line
|
||||
export const DetailsList = ({ items }: DetailsListProps) => (
|
||||
<Section style={boxStyle}>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<Row key={item.label}>
|
||||
<Column style={isLast ? cellStyle : { ...cellStyle, ...dividerStyle }}>
|
||||
<span style={labelStyle}>{item.label}</span>
|
||||
<br />
|
||||
<span style={valueStyle}>{item.value}</span>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
|
||||
const boxStyle = {
|
||||
margin: "8px 0 0 0",
|
||||
backgroundColor: colors.muted,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: radius.box,
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
padding: "12px 16px",
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
fontSize: "12px",
|
||||
lineHeight: "18px",
|
||||
color: colors.mutedForeground,
|
||||
};
|
||||
|
||||
const valueStyle = {
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
fontWeight: 500,
|
||||
color: colors.foreground,
|
||||
};
|
||||
|
||||
const dividerStyle = {
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Text } from "react-email";
|
||||
import { colors } from "./theme";
|
||||
|
||||
interface TextProps {
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Paragraph = ({ children, style }: TextProps) => (
|
||||
<Text style={{ ...paragraphStyle, ...style }}>{children}</Text>
|
||||
);
|
||||
|
||||
export const Muted = ({ children, style }: TextProps) => (
|
||||
<Text style={{ ...mutedStyle, ...style }}>{children}</Text>
|
||||
);
|
||||
|
||||
const paragraphStyle = {
|
||||
margin: "0 0 16px 0",
|
||||
fontSize: "15px",
|
||||
lineHeight: "24px",
|
||||
color: colors.text,
|
||||
};
|
||||
|
||||
const mutedStyle = {
|
||||
margin: "16px 0 0 0",
|
||||
fontSize: "13px",
|
||||
lineHeight: "20px",
|
||||
color: colors.mutedForeground,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Design tokens mirrored from the frontend's app.css (light theme) so emails match the app
|
||||
// Email clients have no CSS variable or oklch support, so the values are the resolved hex equivalents
|
||||
export const colors = {
|
||||
background: "#f5f5f5",
|
||||
card: "#ffffff",
|
||||
border: "#e5e5e5",
|
||||
muted: "#fafafa",
|
||||
foreground: "#0a0a0a",
|
||||
text: "#404040",
|
||||
mutedForeground: "#737373",
|
||||
primary: "#171717",
|
||||
primaryForeground: "#ffffff",
|
||||
};
|
||||
|
||||
export const fonts = {
|
||||
sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Gloock, Georgia, 'Times New Roman', serif",
|
||||
mono: "'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
|
||||
};
|
||||
|
||||
export const radius = {
|
||||
card: "24px",
|
||||
box: "16px",
|
||||
pill: "9999px",
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
import { Muted, Paragraph } from "../components/text";
|
||||
import {
|
||||
type SharedProps,
|
||||
sharedPreviewProps,
|
||||
sharedTemplateProps,
|
||||
} from "../props";
|
||||
|
||||
interface ApiKeyExpiringData {
|
||||
name: string;
|
||||
@@ -9,28 +13,30 @@ interface ApiKeyExpiringData {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface ApiKeyExpiringEmailProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
interface ApiKeyExpiringEmailProps extends SharedProps {
|
||||
data: ApiKeyExpiringData;
|
||||
}
|
||||
|
||||
export const ApiKeyExpiringEmail = ({
|
||||
logoURL,
|
||||
appName,
|
||||
data,
|
||||
...props
|
||||
}: ApiKeyExpiringEmailProps) => (
|
||||
<BaseTemplate logoURL={logoURL} appName={appName}>
|
||||
<CardHeader title="API Key Expiring Soon" warning />
|
||||
<Text>
|
||||
Hello {data.name}, <br />
|
||||
This is a reminder that your API key <strong>
|
||||
{data.apiKeyName}
|
||||
</strong>{" "}
|
||||
will expire on <strong>{data.expiresAt}</strong>.
|
||||
</Text>
|
||||
<BaseTemplate
|
||||
{...props}
|
||||
preview={`Your API key ${data.apiKeyName} expires on ${data.expiresAt}`}
|
||||
>
|
||||
<CardHeader title="API key expiring soon" />
|
||||
<Paragraph>Hello {data.name},</Paragraph>
|
||||
<Paragraph>
|
||||
Your API key <strong>{data.apiKeyName}</strong> will expire on{" "}
|
||||
<strong>{data.expiresAt}</strong>. Anything that uses this key will stop
|
||||
working once it expires.
|
||||
</Paragraph>
|
||||
|
||||
<Text>Please generate a new API key if you need continued access.</Text>
|
||||
<Muted>
|
||||
To keep access, create a new API key in your {props.appName} account
|
||||
settings before then.
|
||||
</Muted>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
@@ -48,8 +54,8 @@ ApiKeyExpiringEmail.TemplateProps = {
|
||||
ApiKeyExpiringEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
data: {
|
||||
name: "Elias Schneider",
|
||||
apiKeyName: "My API Key",
|
||||
expiresAt: "September 30, 2024",
|
||||
name: "Elias",
|
||||
apiKeyName: "CI deploy key",
|
||||
expiresAt: "2026-01-30 12:00:00 UTC",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,41 +1,59 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import { Link } from "react-email";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import { Button } from "../components/button";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
import { Muted, Paragraph } from "../components/text";
|
||||
import { colors } from "../components/theme";
|
||||
import {
|
||||
type SharedProps,
|
||||
sharedPreviewProps,
|
||||
sharedTemplateProps,
|
||||
} from "../props";
|
||||
|
||||
interface EmailVerificationData {
|
||||
userFullName: string;
|
||||
verificationLink: string;
|
||||
}
|
||||
|
||||
interface EmailVerificationProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
interface EmailVerificationProps extends SharedProps {
|
||||
data: EmailVerificationData;
|
||||
}
|
||||
|
||||
export const EmailVerification = ({
|
||||
logoURL,
|
||||
appName,
|
||||
data,
|
||||
...props
|
||||
}: EmailVerificationProps) => (
|
||||
<BaseTemplate logoURL={logoURL} appName={appName}>
|
||||
<CardHeader title="Email Verification" />
|
||||
<BaseTemplate
|
||||
{...props}
|
||||
preview={`Confirm the email address for your ${props.appName} account`}
|
||||
>
|
||||
<CardHeader title="Verify your email address" />
|
||||
<Paragraph>Hello {data.userFullName},</Paragraph>
|
||||
<Paragraph>
|
||||
Click the button below to confirm the email address for your{" "}
|
||||
{props.appName} account. This link expires in 24 hours.
|
||||
</Paragraph>
|
||||
|
||||
<Text>
|
||||
Hello {data.userFullName}, <br />
|
||||
Click the button below to verify your email address for {appName}. This
|
||||
link will expire in 24 hours.
|
||||
<Button href={data.verificationLink}>Verify email address</Button>
|
||||
|
||||
<Muted style={{ marginTop: "32px" }}>
|
||||
Or if you don't like clicking buttons, open this link:
|
||||
<br />
|
||||
</Text>
|
||||
|
||||
<Button href={data.verificationLink}>Verify</Button>
|
||||
<Link href={data.verificationLink} style={linkStyle}>
|
||||
{data.verificationLink}
|
||||
</Link>
|
||||
</Muted>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
export default EmailVerification;
|
||||
|
||||
const linkStyle = {
|
||||
color: colors.mutedForeground,
|
||||
textDecoration: "underline",
|
||||
wordBreak: "break-all" as const,
|
||||
};
|
||||
|
||||
EmailVerification.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
data: {
|
||||
@@ -49,6 +67,6 @@ EmailVerification.PreviewProps = {
|
||||
data: {
|
||||
userFullName: "Tim Cook",
|
||||
verificationLink:
|
||||
"https://localhost:1411/user/verify-email?code=abcdefg12345",
|
||||
"https://id.example.com/user/verify-email?code=abcdefg12345",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Column, Heading, Row, Text } from "@react-email/components";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
import { DetailsList } from "../components/details-list";
|
||||
import { Muted, Paragraph } from "../components/text";
|
||||
import {
|
||||
type SharedProps,
|
||||
sharedPreviewProps,
|
||||
sharedTemplateProps,
|
||||
} from "../props";
|
||||
|
||||
interface SignInData {
|
||||
location: string;
|
||||
@@ -10,78 +15,44 @@ interface SignInData {
|
||||
dateTime: string;
|
||||
}
|
||||
|
||||
interface NewSignInEmailProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
interface NewSignInEmailProps extends SharedProps {
|
||||
data: SignInData;
|
||||
}
|
||||
|
||||
export const NewSignInEmail = ({
|
||||
logoURL,
|
||||
appName,
|
||||
data,
|
||||
}: NewSignInEmailProps) => (
|
||||
<BaseTemplate logoURL={logoURL} appName={appName}>
|
||||
<CardHeader title="New Sign-In Detected" warning />
|
||||
<Text>
|
||||
Your {appName} account was recently accessed from a new IP address or
|
||||
browser. If you recognize this activity, no further action is required.
|
||||
</Text>
|
||||
<Heading
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
fontWeight: "bold",
|
||||
margin: "30px 0 10px 0",
|
||||
}}
|
||||
as="h4"
|
||||
>
|
||||
Details
|
||||
</Heading>
|
||||
export const NewSignInEmail = ({ data, ...props }: NewSignInEmailProps) => (
|
||||
<BaseTemplate
|
||||
{...props}
|
||||
preview={`A new sign-in to your ${props.appName} account was detected`}
|
||||
>
|
||||
<CardHeader title="New sign-in detected" />
|
||||
<Paragraph>
|
||||
Your {props.appName} account was recently accessed from a new IP address
|
||||
or browser. If this was you, no further action is needed.
|
||||
</Paragraph>
|
||||
|
||||
<Row>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>Approximate Location</Text>
|
||||
<Text style={detailsBoxValueStyle}>{data.location}</Text>
|
||||
</Column>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>IP Address</Text>
|
||||
<Text style={detailsBoxValueStyle}>{data.ipAddress}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<DetailsList
|
||||
items={[
|
||||
{ label: "Approximate location", value: data.location },
|
||||
{ label: "IP address", value: data.ipAddress },
|
||||
{ label: "Device", value: data.device },
|
||||
{ label: "Time", value: data.dateTime },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Row style={{ marginTop: "10px" }}>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>Device</Text>
|
||||
<Text style={detailsBoxValueStyle}>{data.device}</Text>
|
||||
</Column>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>Sign-In Time</Text>
|
||||
<Text style={detailsBoxValueStyle}>{data.dateTime}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Muted>
|
||||
If you don't recognize this activity, review the passkeys in your{" "}
|
||||
{props.appName} account settings and remove any you don't recognize.
|
||||
</Muted>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
export default NewSignInEmail;
|
||||
|
||||
const detailsBoxStyle = {
|
||||
width: "225px",
|
||||
};
|
||||
|
||||
const detailsLabelStyle = {
|
||||
margin: 0,
|
||||
fontSize: "12px",
|
||||
color: "gray",
|
||||
};
|
||||
|
||||
const detailsBoxValueStyle = {
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
NewSignInEmail.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
data: {
|
||||
location: "{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}",
|
||||
location:
|
||||
"{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}",
|
||||
ipAddress: "{{.Data.IPAddress}}",
|
||||
device: "{{.Data.Device}}",
|
||||
dateTime: '{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}',
|
||||
@@ -92,8 +63,8 @@ NewSignInEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
data: {
|
||||
location: "San Francisco, USA",
|
||||
ipAddress: "127.0.0.1",
|
||||
ipAddress: "203.0.113.42",
|
||||
device: "Chrome on macOS",
|
||||
dateTime: "2024-01-01 12:00 PM UTC",
|
||||
dateTime: "January 2, 2026 at 3:04 PM UTC",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Link, Text } from "@react-email/components";
|
||||
import { Link } from "react-email";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import { Button } from "../components/button";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
import { CodeBox } from "../components/code-box";
|
||||
import { Muted, Paragraph } from "../components/text";
|
||||
import { colors } from "../components/theme";
|
||||
import {
|
||||
type SharedProps,
|
||||
sharedPreviewProps,
|
||||
sharedTemplateProps,
|
||||
} from "../props";
|
||||
|
||||
interface OneTimeAccessData {
|
||||
code: string;
|
||||
@@ -11,43 +18,44 @@ interface OneTimeAccessData {
|
||||
expirationString: string;
|
||||
}
|
||||
|
||||
interface OneTimeAccessEmailProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
interface OneTimeAccessEmailProps extends SharedProps {
|
||||
data: OneTimeAccessData;
|
||||
}
|
||||
|
||||
export const OneTimeAccessEmail = ({
|
||||
logoURL,
|
||||
appName,
|
||||
data,
|
||||
...props
|
||||
}: OneTimeAccessEmailProps) => (
|
||||
<BaseTemplate logoURL={logoURL} appName={appName}>
|
||||
<CardHeader title="Your Login Code" />
|
||||
<BaseTemplate
|
||||
{...props}
|
||||
preview={`Your ${props.appName} login code is ${data.code}`}
|
||||
>
|
||||
<CardHeader title="Your login code" />
|
||||
<Paragraph>
|
||||
Use the code below to sign in to {props.appName}. It expires in{" "}
|
||||
{data.expirationString}.
|
||||
</Paragraph>
|
||||
|
||||
<Text>
|
||||
Click the button below to sign in to {appName} with a login code.
|
||||
<br />
|
||||
Or visit{" "}
|
||||
<CodeBox code={data.code} />
|
||||
|
||||
<Button href={data.buttonCodeLink}>Sign in</Button>
|
||||
|
||||
<Muted style={{ marginTop: "32px" }}>
|
||||
Or open{" "}
|
||||
<Link href={data.loginLink} style={linkStyle}>
|
||||
{data.loginLink}
|
||||
</Link>{" "}
|
||||
and enter the code <strong>{data.code}</strong>.
|
||||
<br />
|
||||
<br />
|
||||
This code expires in {data.expirationString}.
|
||||
</Text>
|
||||
|
||||
<Button href={data.buttonCodeLink}>Sign In</Button>
|
||||
and enter the code manually.
|
||||
</Muted>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
export default OneTimeAccessEmail;
|
||||
|
||||
const linkStyle = {
|
||||
color: "#000",
|
||||
color: colors.mutedForeground,
|
||||
textDecoration: "underline",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
wordBreak: "break-all" as const,
|
||||
};
|
||||
|
||||
OneTimeAccessEmail.TemplateProps = {
|
||||
@@ -64,8 +72,8 @@ OneTimeAccessEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
data: {
|
||||
code: "123456",
|
||||
loginLink: "https://example.com/login",
|
||||
buttonCodeLink: "https://example.com/login?code=123456",
|
||||
loginLink: "https://id.example.com/lc",
|
||||
buttonCodeLink: "https://id.example.com/lc/123456",
|
||||
expirationString: "15 minutes",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
import { Paragraph } from "../components/text";
|
||||
import {
|
||||
type SharedProps,
|
||||
sharedPreviewProps,
|
||||
sharedTemplateProps,
|
||||
} from "../props";
|
||||
|
||||
interface TestEmailProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export const TestEmail = ({ logoURL, appName }: TestEmailProps) => (
|
||||
<BaseTemplate logoURL={logoURL} appName={appName}>
|
||||
<CardHeader title="Test Email" />
|
||||
<Text>Your email setup is working correctly!</Text>
|
||||
export const TestEmail = (props: SharedProps) => (
|
||||
<BaseTemplate {...props} preview="Your email setup is working correctly">
|
||||
<CardHeader title="Test email" />
|
||||
<Paragraph>Your email setup is working correctly!</Paragraph>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "1.0.12",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-email": "6.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-email/preview-server": "5.2.10",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react-email": "6.9.5",
|
||||
"tsx": "^4.22.2"
|
||||
"@react-email/ui": "6.9.5",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"tsx": "^4.23.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
export const sharedPreviewProps = {
|
||||
export interface SharedProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
appURL: string;
|
||||
}
|
||||
|
||||
export const sharedPreviewProps: SharedProps = {
|
||||
logoURL: "https://pocket-id.org/img/logo.png",
|
||||
appName: "Pocket ID",
|
||||
appURL: "https://id.example.com",
|
||||
};
|
||||
|
||||
export const sharedTemplateProps = {
|
||||
export const sharedTemplateProps: SharedProps = {
|
||||
logoURL: "{{.LogoURL}}",
|
||||
appName: "{{.AppName}}",
|
||||
appURL: "{{.AppURL}}",
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "2.15.0",
|
||||
"version": "2.16.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -53,6 +53,8 @@
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^3.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"rollup": "^4.63.3",
|
||||
"rollup-plugin-sbom": "4.0.0",
|
||||
"shadcn-svelte": "^1.3.0",
|
||||
"svelte": "^5.55.8",
|
||||
"svelte-check": "^4.4.8",
|
||||
|
||||
+11
-1
@@ -1,13 +1,23 @@
|
||||
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import sbom from 'rollup-plugin-sbom';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
const frontendSbom = sbom({
|
||||
includeWellKnown: false,
|
||||
outDir: 'cyclonedx',
|
||||
outFilename: 'frontend.cdx',
|
||||
saveTimestamp: false
|
||||
}) as Plugin;
|
||||
frontendSbom.applyToEnvironment = (environment) => environment.name === 'client';
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
tailwindcss(),
|
||||
frontendSbom,
|
||||
paraglideVitePlugin({
|
||||
project: './project.inlang',
|
||||
outdir: './src/lib/paraglide',
|
||||
|
||||
Generated
+722
-328
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the script is being run from the root of the project
|
||||
if [ ! -f .version ] || [ ! -f frontend/package.json ] || [ ! -f CHANGELOG.md ]; then
|
||||
echo "Error: This script must be run from the root of the project."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if git cliff is installed
|
||||
if ! command -v git-cliff &>/dev/null; then
|
||||
echo "Error: git cliff is not installed. Please install it from https://git-cliff.org/docs/installation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if GitHub CLI is installed
|
||||
if ! command -v gh &>/dev/null; then
|
||||
echo "Error: GitHub CLI (gh) is not installed. Please install it and authenticate using 'gh auth login'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Snyk CLI is installed
|
||||
if ! command -v snyk &>/dev/null; then
|
||||
echo "Error: Snyk CLI is not installed. Please install it and authenticate using 'snyk auth'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're on the main branch
|
||||
if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then
|
||||
echo "Error: This script must be run on the main branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse command line arguments
|
||||
FORCE_MAJOR=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--major)
|
||||
FORCE_MAJOR=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
# Unknown option
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
BUMP_ARGUMENTS=(--bumped-version --unreleased --offline)
|
||||
if [ "$FORCE_MAJOR" == true ]; then
|
||||
BUMP_ARGUMENTS+=(--bump major)
|
||||
fi
|
||||
|
||||
# Calculate the next version from the unreleased conventional commits
|
||||
if ! NEW_VERSION=$(git cliff "${BUMP_ARGUMENTS[@]}"); then
|
||||
echo "Error: Could not calculate the next version."
|
||||
exit 1
|
||||
fi
|
||||
NEW_VERSION=${NEW_VERSION#v}
|
||||
|
||||
if [ "$NEW_VERSION" == "$(cat .version)" ]; then
|
||||
echo "No commits requiring a version bump found since the latest release. No new release will be created."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Running Snyk dependency scan..."
|
||||
if ! snyk test --all-projects --dev --detection-depth=3 --strict-out-of-sync=false --severity-threshold=high; then
|
||||
echo "Error: Snyk detected high-severity vulnerable dependencies. Release creation aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Confirm release creation
|
||||
read -p "This will create a new release with version $NEW_VERSION. Do you want to proceed? (y/n) " CONFIRM
|
||||
if [[ "$CONFIRM" != "y" ]]; then
|
||||
echo "Release process canceled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update the .version file with the new version
|
||||
echo $NEW_VERSION >.version
|
||||
git add .version
|
||||
|
||||
# Update version in frontend/package.json
|
||||
jq --arg new_version "$NEW_VERSION" '.version = $new_version' frontend/package.json >frontend/package_tmp.json && mv frontend/package_tmp.json frontend/package.json
|
||||
pnpm --dir frontend exec prettier --write package.json
|
||||
git add frontend/package.json
|
||||
|
||||
# Generate changelog
|
||||
echo "Generating changelog..."
|
||||
git cliff --github-token=$(gh auth token) --prepend CHANGELOG.md --tag "v$NEW_VERSION" --unreleased
|
||||
git add CHANGELOG.md
|
||||
|
||||
# Commit the changes with the new version
|
||||
git commit -m "release: $NEW_VERSION"
|
||||
|
||||
# Create a Git tag with the new version
|
||||
git tag "v$NEW_VERSION"
|
||||
|
||||
# Push the commit and the tag to the repository
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
# Extract the changelog content for the latest release
|
||||
echo "Extracting changelog content for version $NEW_VERSION..."
|
||||
CHANGELOG=$(awk '/^## v[0-9]/ { if (found) exit; found=1; next } found' CHANGELOG.md)
|
||||
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "Error: Could not extract changelog for version $NEW_VERSION."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create the release on GitHub
|
||||
echo "Creating GitHub release..."
|
||||
gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes "$CHANGELOG" --draft
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "GitHub release created successfully."
|
||||
else
|
||||
echo "Error: Failed to create GitHub release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release process complete. New version: $NEW_VERSION"
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
artifact_path=$1
|
||||
document_path=$2
|
||||
source_name=$3
|
||||
source_version=$4
|
||||
frontend_sbom_path="../.tmp/frontend.cdx.json"
|
||||
|
||||
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/pocket-id-binary-sbom.XXXXXX")
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
|
||||
cp "$artifact_path" "$work_dir/$(basename "$artifact_path")"
|
||||
cp "$frontend_sbom_path" "$work_dir/frontend.cdx.json"
|
||||
|
||||
syft "dir:$work_dir" \
|
||||
--select-catalogers "+sbom-cataloger" \
|
||||
--source-name "$source_name" \
|
||||
--source-version "$source_version" \
|
||||
--output "spdx-json=$document_path" \
|
||||
--enrich all
|
||||
Reference in New Issue
Block a user