@smi-digital/create-smi-app 2.9.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -505,10 +505,10 @@ async function createApps(projectRoot, targets) {
505
505
  );
506
506
  }
507
507
  await runStep(
508
- "Adding Postgres driver (pg) for production",
508
+ "Adding Postgres + email-provider dependencies for production",
509
509
  async () => runCommandQuiet(
510
510
  "npm",
511
- ["install", "pg"],
511
+ ["install", "pg", "@strapi/provider-email-nodemailer"],
512
512
  join4(projectRoot, target.directory)
513
513
  )
514
514
  );
@@ -702,21 +702,23 @@ async function activateVaultFilter(projectRoot) {
702
702
  } catch {
703
703
  return;
704
704
  }
705
- const setFilter = async (key, value) => runCommandQuiet(
706
- "git",
707
- ["config", `filter.ansible-vault.${key}`, value],
708
- projectRoot
709
- );
705
+ const setConfig = async (key, value) => runCommandQuiet("git", ["config", key, value], projectRoot);
710
706
  try {
711
- await setFilter(
712
- "clean",
713
- "ansible-vault encrypt --vault-password-file .vault-password --output=- -"
714
- );
715
- await setFilter(
716
- "smudge",
707
+ await setConfig("filter.ansible-vault.clean", "sh .gitvault/clean.sh %f");
708
+ await setConfig(
709
+ "filter.ansible-vault.smudge",
717
710
  "ansible-vault decrypt --vault-password-file .vault-password --output=- -"
718
711
  );
719
- await setFilter("required", "true");
712
+ await setConfig("filter.ansible-vault.required", "true");
713
+ await setConfig(
714
+ "merge.ansible-vault.name",
715
+ "Ansible-Vault aware 3-way merge"
716
+ );
717
+ await setConfig(
718
+ "merge.ansible-vault.driver",
719
+ "sh .gitvault/merge.sh %O %A %B %P"
720
+ );
721
+ await setConfig("diff.ansible-vault.textconv", "sh .gitvault/textconv.sh");
720
722
  } catch {
721
723
  console.warn(
722
724
  "Could not configure the Ansible-Vault git filter automatically. Run `npm run setup:vault` before committing."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smi-digital/create-smi-app",
3
- "version": "2.9.1",
3
+ "version": "2.10.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "prettier": "npx prettier --write .",
10
10
  "lint": "npx eslint .",
11
11
  "prepare": "husky",
12
- "setup:vault": "git config filter.ansible-vault.clean \"ansible-vault encrypt --vault-password-file .vault-password --output=- -\" && git config filter.ansible-vault.smudge \"ansible-vault decrypt --vault-password-file .vault-password --output=- -\" && git config filter.ansible-vault.required true && echo '✅ Ansible-Vault Git Filters configured successfully!'",
12
+ "setup:vault": "git config filter.ansible-vault.clean 'sh .gitvault/clean.sh %f' && git config filter.ansible-vault.smudge 'ansible-vault decrypt --vault-password-file .vault-password --output=- -' && git config filter.ansible-vault.required true && git config merge.ansible-vault.name 'Ansible-Vault aware 3-way merge' && git config merge.ansible-vault.driver 'sh .gitvault/merge.sh %O %A %B %P' && git config diff.ansible-vault.textconv 'sh .gitvault/textconv.sh' && echo '✅ Ansible-Vault Git filters configured successfully!'",
13
13
  "knip": "knip",
14
14
  "depcruise": "depcruise . --exclude \"node_modules|dist|.husky|test\"",
15
15
  "depcheck": "npm run knip && npm run depcruise",
@@ -1,2 +1,6 @@
1
1
  # Use Ansible-Vault Git filters for production environment files
2
2
  production.*.env filter=ansible-vault diff=ansible-vault merge=ansible-vault
3
+
4
+ # The vault filter/merge/diff helpers are invoked as shell scripts; force LF so a
5
+ # CRLF checkout on Windows can't break them.
6
+ .gitvault/*.sh text eol=lf
@@ -0,0 +1,41 @@
1
+ #!/bin/sh
2
+ # Idempotent Ansible-Vault "clean" filter (encrypt on the way INTO git).
3
+ #
4
+ # Why this exists: `ansible-vault encrypt` uses a random salt, so encrypting the
5
+ # same plaintext twice produces DIFFERENT ciphertext. With a naive clean filter
6
+ # git re-encrypts on every status/diff/checkout and therefore ALWAYS sees the
7
+ # file as "modified" — phantom diffs, salt-only churn baked into history, and
8
+ # rebases/stashes that conflict on the ciphertext.
9
+ #
10
+ # This wrapper makes the filter deterministic: if the plaintext is unchanged
11
+ # from what is already committed, it re-emits the EXISTING ciphertext verbatim
12
+ # (no re-salt), so git sees no change. Only a real plaintext edit re-encrypts.
13
+ #
14
+ # Invoked by git as: sh .gitvault/clean.sh %f (%f = repo-relative path)
15
+ # stdin = working-tree plaintext
16
+ # stdout = ciphertext to store in the index/commit
17
+ set -eu
18
+
19
+ path="${1:-}"
20
+ pwfile=".vault-password"
21
+
22
+ new="$(mktemp)"
23
+ old_cipher="$(mktemp)"
24
+ old_plain="$(mktemp)"
25
+ trap 'rm -f "$new" "$old_cipher" "$old_plain"' EXIT
26
+
27
+ cat > "$new"
28
+
29
+ # The ciphertext already recorded for this path in the index (if any). Missing on
30
+ # a first add, or unreadable if it was committed as plaintext once — both cases
31
+ # fall through to a fresh encrypt.
32
+ if [ -n "$path" ] \
33
+ && git cat-file -p ":$path" > "$old_cipher" 2>/dev/null \
34
+ && ansible-vault decrypt --vault-password-file "$pwfile" --output=- "$old_cipher" > "$old_plain" 2>/dev/null \
35
+ && cmp -s "$new" "$old_plain"; then
36
+ # Plaintext identical to what's stored → reuse the stored ciphertext (no re-salt).
37
+ cat "$old_cipher"
38
+ else
39
+ # New file or a genuine plaintext change → encrypt fresh.
40
+ ansible-vault encrypt --vault-password-file "$pwfile" --output=- "$new"
41
+ fi
@@ -0,0 +1,42 @@
1
+ #!/bin/sh
2
+ # Ansible-Vault aware merge driver — merges at the PLAINTEXT level.
3
+ #
4
+ # Git's default merge would 3-way-merge the opaque ciphertext and conflict on
5
+ # nearly everything (different salts even when the plaintext agrees). This driver
6
+ # decrypts the three inputs, runs a normal 3-way text merge, then RE-ENCRYPTS the
7
+ # result. Git stores %A's bytes verbatim and then runs the smudge filter on them,
8
+ # so %A must contain CIPHERTEXT (verified behaviour).
9
+ #
10
+ # Invoked by git as: sh .gitvault/merge.sh %O %A %B %P
11
+ # %O = common-ancestor (base) ciphertext file
12
+ # %A = our version ciphertext file (ALSO the output file git reads back)
13
+ # %B = their version ciphertext file
14
+ # %P = the real pathname (used only for conflict-marker labels)
15
+ # Exit 0 = clean merge; non-zero = conflict (git marks the path unmerged, then
16
+ # smudges %A so the working tree shows readable plaintext conflict markers).
17
+ set -eu
18
+
19
+ base="$1"; ours="$2"; theirs="$3"; path="${4:-file}"
20
+ pwfile=".vault-password"
21
+
22
+ # Decrypt $1 to stdout; if it isn't vault-encrypted (e.g. an empty base), pass
23
+ # it through unchanged so the merge still has something to work with.
24
+ dec() {
25
+ ansible-vault decrypt --vault-password-file "$pwfile" --output=- "$1" 2>/dev/null || cat "$1"
26
+ }
27
+
28
+ o="$(mktemp)"; a="$(mktemp)"; b="$(mktemp)"
29
+ trap 'rm -f "$o" "$a" "$b"' EXIT
30
+ dec "$base" > "$o"
31
+ dec "$ours" > "$a"
32
+ dec "$theirs" > "$b"
33
+
34
+ # 3-way merge on plaintext, written in place into "$a". Returns the conflict
35
+ # count (0 = clean); capture it instead of letting `set -e` abort.
36
+ status=0
37
+ git merge-file -L "ours ($path)" -L "base ($path)" -L "theirs ($path)" "$a" "$o" "$b" || status=$?
38
+
39
+ # Re-encrypt the (possibly conflict-marked) result back into %A as ciphertext.
40
+ ansible-vault encrypt --vault-password-file "$pwfile" --output=- "$a" > "$ours"
41
+
42
+ exit "$status"
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # Ansible-Vault diff textconv: render a vault file as plaintext for `git diff`.
3
+ #
4
+ # git applies this to BOTH sides of a diff — the committed blob (ciphertext) and
5
+ # the working-tree copy (already decrypted by the smudge filter). So decrypt when
6
+ # possible and fall back to cat when the input isn't vault-encrypted, otherwise
7
+ # the working-tree side errors with "Input is not vault encrypted data".
8
+ #
9
+ # Invoked by git as: sh .gitvault/textconv.sh <path>
10
+ ansible-vault decrypt --vault-password-file .vault-password --output=- "$1" 2>/dev/null || cat "$1"
@@ -0,0 +1,20 @@
1
+ export default ({ env }) => ({
2
+ email: {
3
+ config: {
4
+ provider: 'nodemailer',
5
+ providerOptions: {
6
+ host: env('SMTP_HOST', 'smtp.example.com'),
7
+ port: env.int('SMTP_PORT', 587),
8
+ auth: {
9
+ user: env('SMTP_USER'),
10
+ pass: env('SMTP_PASSWORD'),
11
+ },
12
+ secure: false, // true for 465, false for other ports
13
+ },
14
+ settings: {
15
+ defaultFrom: env('SMTP_DEFAULT_FROM'),
16
+ defaultReplyTo: env('SMTP_DEFAULT_REPLY_TO'),
17
+ },
18
+ },
19
+ },
20
+ });
@@ -0,0 +1,15 @@
1
+ import type { Core } from '@strapi/strapi';
2
+
3
+ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
4
+ host: env('HOST', '0.0.0.0'),
5
+ port: env.int('PORT', 1337),
6
+ // Public URL of the CMS backend itself. Used to build absolute links such as
7
+ // the admin password-reset email; without it Strapi falls back to host:port
8
+ // (e.g. http://0.0.0.0:1337). Set via PUBLIC_URL in production.backend.env.
9
+ url: env('PUBLIC_URL', undefined),
10
+ app: {
11
+ keys: env.array('APP_KEYS'),
12
+ },
13
+ });
14
+
15
+ export default config;
@@ -0,0 +1,223 @@
1
+ import type { Core } from '@strapi/strapi';
2
+
3
+ export type EmailLocale = 'de' | 'en';
4
+
5
+ export interface ResetPasswordTemplateVars {
6
+ USER: { username: string; email: string };
7
+ URL: string;
8
+ TOKEN: string;
9
+ }
10
+
11
+ export interface EmailConfirmationTemplateVars {
12
+ USER: { username: string; email: string };
13
+ URL: string;
14
+ CODE: string;
15
+ }
16
+
17
+ // ─── Reset Password ────────────────────────────────────────────────────────────
18
+
19
+ const resetPasswordSubject: Record<EmailLocale, string> = {
20
+ de: 'Passwort zurücksetzen – __PROJECT_DOMAIN__',
21
+ en: 'Reset your password – __PROJECT_DOMAIN__',
22
+ };
23
+
24
+ const resetPasswordHtml: Record<EmailLocale, (v: ResetPasswordTemplateVars) => string> = {
25
+ de: (v) => `
26
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
27
+ <h2>Hallo ${v.USER.username},</h2>
28
+ <p>wir haben eine Anfrage zum Zurücksetzen deines Passworts erhalten.</p>
29
+ <p>Klicke auf den folgenden Link, um dein Passwort zurückzusetzen:</p>
30
+ <p>
31
+ <a href="${v.URL}?code=${v.TOKEN}"
32
+ style="display:inline-block;padding:12px 24px;background:#1C135D;color:#fff;text-decoration:none;border-radius:4px">
33
+ Passwort zurücksetzen
34
+ </a>
35
+ </p>
36
+ <p style="color:#888;font-size:13px">
37
+ Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.
38
+ </p>
39
+ </div>`,
40
+
41
+ en: (v) => `
42
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
43
+ <h2>Hi ${v.USER.username},</h2>
44
+ <p>We received a request to reset your password.</p>
45
+ <p>Click the link below to choose a new password:</p>
46
+ <p>
47
+ <a href="${v.URL}?code=${v.TOKEN}"
48
+ style="display:inline-block;padding:12px 24px;background:#1C135D;color:#fff;text-decoration:none;border-radius:4px">
49
+ Reset password
50
+ </a>
51
+ </p>
52
+ <p style="color:#888;font-size:13px">
53
+ If you did not request this, you can safely ignore this email.
54
+ </p>
55
+ </div>`,
56
+ };
57
+
58
+ const resetPasswordText: Record<EmailLocale, (v: ResetPasswordTemplateVars) => string> = {
59
+ de: (v) =>
60
+ `Hallo ${v.USER.username},\n\nKlicke auf diesen Link, um dein Passwort zurückzusetzen:\n${v.URL}?code=${v.TOKEN}\n\nFalls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.`,
61
+ en: (v) =>
62
+ `Hi ${v.USER.username},\n\nClick this link to reset your password:\n${v.URL}?code=${v.TOKEN}\n\nIf you did not request this, ignore this email.`,
63
+ };
64
+
65
+ // ─── Email Confirmation ────────────────────────────────────────────────────────
66
+
67
+ const emailConfirmationSubject: Record<EmailLocale, string> = {
68
+ de: 'E-Mail-Adresse bestätigen – __PROJECT_DOMAIN__',
69
+ en: 'Confirm your email address – __PROJECT_DOMAIN__',
70
+ };
71
+
72
+ const emailConfirmationHtml: Record<EmailLocale, (v: EmailConfirmationTemplateVars) => string> = {
73
+ de: (v) => `
74
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
75
+ <h2>Hallo ${v.USER.username},</h2>
76
+ <p>Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Link klickst:</p>
77
+ <p>
78
+ <a href="${v.URL}?confirmation=${v.CODE}"
79
+ style="display:inline-block;padding:12px 24px;background:#1C135D;color:#fff;text-decoration:none;border-radius:4px">
80
+ E-Mail bestätigen
81
+ </a>
82
+ </p>
83
+ <p style="color:#888;font-size:13px">
84
+ Falls du kein Konto erstellt hast, kannst du diese E-Mail ignorieren.
85
+ </p>
86
+ </div>`,
87
+
88
+ en: (v) => `
89
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
90
+ <h2>Hi ${v.USER.username},</h2>
91
+ <p>Please confirm your email address by clicking the link below:</p>
92
+ <p>
93
+ <a href="${v.URL}?confirmation=${v.CODE}"
94
+ style="display:inline-block;padding:12px 24px;background:#1C135D;color:#fff;text-decoration:none;border-radius:4px">
95
+ Confirm email
96
+ </a>
97
+ </p>
98
+ <p style="color:#888;font-size:13px">
99
+ If you did not create an account, you can safely ignore this email.
100
+ </p>
101
+ </div>`,
102
+ };
103
+
104
+ const emailConfirmationText: Record<EmailLocale, (v: EmailConfirmationTemplateVars) => string> = {
105
+ de: (v) =>
106
+ `Hallo ${v.USER.username},\n\nBitte bestätige deine E-Mail-Adresse:\n${v.URL}?confirmation=${v.CODE}\n\nFalls du kein Konto erstellt hast, ignoriere diese E-Mail.`,
107
+ en: (v) =>
108
+ `Hi ${v.USER.username},\n\nPlease confirm your email address:\n${v.URL}?confirmation=${v.CODE}\n\nIf you did not create an account, ignore this email.`,
109
+ };
110
+
111
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
112
+
113
+ function resolveLocale(raw: unknown): EmailLocale {
114
+ if (raw === 'en') return 'en';
115
+ return 'de';
116
+ }
117
+
118
+ // ─── Extension ────────────────────────────────────────────────────────────────
119
+
120
+ export default (plugin: any): any => {
121
+ const originalBootstrap = plugin.bootstrap;
122
+
123
+ plugin.bootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise<void> => {
124
+ // Run the original bootstrap first
125
+ if (originalBootstrap) {
126
+ await originalBootstrap({ strapi });
127
+ }
128
+
129
+ const authController = strapi
130
+ .plugin('users-permissions')
131
+ .controller('auth') as Record<string, Function>;
132
+
133
+ // ── Override: forgotPassword ──────────────────────────────────────────
134
+ authController.forgotPassword = async (ctx: any): Promise<void> => {
135
+ const locale = resolveLocale(ctx.request.body?.locale ?? ctx.query?.locale);
136
+ const email: string = (ctx.request.body?.email as string)?.toLowerCase();
137
+
138
+ if (!email) {
139
+ return ctx.badRequest('email is required');
140
+ }
141
+
142
+ const user = await strapi.db
143
+ .query('plugin::users-permissions.user')
144
+ .findOne({ where: { email } });
145
+
146
+ if (!user || user.blocked) {
147
+ ctx.send({ ok: true });
148
+ return;
149
+ }
150
+
151
+ const crypto = await import('crypto');
152
+ const resetPasswordToken = crypto.randomBytes(64).toString('hex');
153
+
154
+ const baseUrl = (process.env.PROD_URL ?? '').replace(/\/+$/, '');
155
+ const resetUrl = `${baseUrl}/reset-password`;
156
+ const vars: ResetPasswordTemplateVars = {
157
+ USER: { username: user.username, email: user.email },
158
+ URL: resetUrl,
159
+ TOKEN: resetPasswordToken,
160
+ };
161
+
162
+ await strapi.db
163
+ .query('plugin::users-permissions.user')
164
+ .update({ where: { id: user.id }, data: { resetPasswordToken } });
165
+
166
+ await strapi.plugin('email').provider.send({
167
+ to: user.email,
168
+ from: process.env.SMTP_DEFAULT_FROM,
169
+ subject: resetPasswordSubject[locale],
170
+ html: resetPasswordHtml[locale](vars),
171
+ text: resetPasswordText[locale](vars),
172
+ });
173
+
174
+ ctx.send({ ok: true });
175
+ };
176
+
177
+ // ── Override: sendEmailConfirmation ───────────────────────────────────
178
+ authController.sendEmailConfirmation = async (ctx: any): Promise<void> => {
179
+ const locale = resolveLocale(ctx.request.body?.locale ?? ctx.query?.locale);
180
+ const email: string = (ctx.request.body?.email as string)?.toLowerCase();
181
+
182
+ if (!email) {
183
+ return ctx.badRequest('email is required');
184
+ }
185
+
186
+ const user = await strapi.db
187
+ .query('plugin::users-permissions.user')
188
+ .findOne({ where: { email } });
189
+
190
+ if (!user) {
191
+ return ctx.badRequest('No user found with this email');
192
+ }
193
+
194
+ if (user.confirmed) {
195
+ return ctx.badRequest('This email is already confirmed');
196
+ }
197
+
198
+ const serverUrl: string = ((strapi.config.get('server.url') as string) ?? '').replace(
199
+ /\/+$/,
200
+ '',
201
+ );
202
+ const confirmUrl = `${serverUrl}/api/auth/email-confirmation`;
203
+
204
+ const vars: EmailConfirmationTemplateVars = {
205
+ USER: { username: user.username, email: user.email },
206
+ URL: confirmUrl,
207
+ CODE: user.confirmationToken ?? '',
208
+ };
209
+
210
+ await strapi.plugin('email').provider.send({
211
+ to: user.email,
212
+ from: process.env.SMTP_DEFAULT_FROM,
213
+ subject: emailConfirmationSubject[locale],
214
+ html: emailConfirmationHtml[locale](vars),
215
+ text: emailConfirmationText[locale](vars),
216
+ });
217
+
218
+ ctx.send({ email, sent: true });
219
+ };
220
+ };
221
+
222
+ return plugin;
223
+ };
@@ -49,6 +49,18 @@
49
49
  }
50
50
  ],
51
51
  "cdFiles": [
52
+ {
53
+ "template": "backend/config/plugins.ts.template",
54
+ "target": "backend/config/plugins.ts"
55
+ },
56
+ {
57
+ "template": "backend/config/server.ts.template",
58
+ "target": "backend/config/server.ts"
59
+ },
60
+ {
61
+ "template": "backend/src/extensions/users-permissions/strapi-server.ts.template",
62
+ "target": "backend/src/extensions/users-permissions/strapi-server.ts"
63
+ },
52
64
  {
53
65
  "template": "docker-compose.yml.template",
54
66
  "target": "docker-compose.yml"
@@ -81,6 +93,18 @@
81
93
  "template": ".gitattributes.template",
82
94
  "target": ".gitattributes"
83
95
  },
96
+ {
97
+ "template": ".gitvault/clean.sh.template",
98
+ "target": ".gitvault/clean.sh"
99
+ },
100
+ {
101
+ "template": ".gitvault/merge.sh.template",
102
+ "target": ".gitvault/merge.sh"
103
+ },
104
+ {
105
+ "template": ".gitvault/textconv.sh.template",
106
+ "target": ".gitvault/textconv.sh"
107
+ },
84
108
  {
85
109
  "template": "production.frontend.env.template",
86
110
  "target": "production.frontend.env"
@@ -28,6 +28,25 @@ DATABASE_USERNAME=__DB_NAME__
28
28
  DATABASE_PASSWORD=__DB_PASSWORD__
29
29
  DATABASE_SSL=false
30
30
 
31
+ # Public URLs.
32
+ # PROD_URL → the public site (frontend). Used to build the end-user password
33
+ # reset link (<PROD_URL>/reset-password).
34
+ # PUBLIC_URL → the CMS backend itself. Used for absolute backend links such as
35
+ # the admin password-reset email and the email-confirmation link.
36
+ PROD_URL=https://__PROJECT_DOMAIN__
37
+ PUBLIC_URL=https://__BACKEND_DOMAIN__
38
+
39
+ # SMTP / contact form (FILL IN before relying on email — password reset,
40
+ # email confirmation, contact flow).
41
+ SMTP_HOST=
42
+ SMTP_PORT=587
43
+ SMTP_USER=
44
+ SMTP_PASSWORD=
45
+ SMTP_DEFAULT_FROM=
46
+ SMTP_DEFAULT_REPLY_TO=
47
+ CONTACTFLOW_SENDER=
48
+ CONTACTFLOW_RECEIVER=
49
+
31
50
  # Production Settings
32
51
  HOST=0.0.0.0
33
52
  PORT=1337