Skip to content

Commit 04274ec

Browse files
authored
✨ add alert for upcoming privacy change (#4806)
1 parent 426926a commit 04274ec

15 files changed

Lines changed: 488 additions & 190 deletions

File tree

app/Dto/PrivacyPolicyWithAcceptance.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@ public function __construct(
1313
public PrivacyPolicy $policy,
1414
public ?Carbon $acceptedAt,
1515
public bool $hasOldAcceptance,
16+
public ?PrivacyPolicy $upcomingPolicy = null,
17+
public ?Carbon $upcomingAcceptedAt = null,
1618
) {}
1719
}

app/Http/Controllers/API/v1/AlertController.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Http\Resources\AlertResource;
77
use App\Models\Alert;
88
use App\Models\AlertTranslation;
9+
use App\Services\PrivacyPolicyService;
910
use Illuminate\Http\JsonResponse;
1011
use Illuminate\Http\Request;
1112
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -17,6 +18,10 @@
1718

1819
class AlertController extends Controller
1920
{
21+
public function __construct(
22+
private readonly PrivacyPolicyService $privacyPolicyService,
23+
) {}
24+
2025
#[OA\Get(
2126
path: '/alerts',
2227
operationId: 'getAlerts',
@@ -125,6 +130,28 @@ function () use ($userId): bool {
125130
}
126131
}
127132

133+
$upcomingPolicy = $this->privacyPolicyService->getUpcomingPolicy();
134+
$user = auth()->user();
135+
136+
if ($upcomingPolicy !== null && $user !== null && !$this->privacyPolicyService->hasUserAcceptedPolicy($user, $upcomingPolicy)) {
137+
$alert = new Alert();
138+
$alert->id = 'privacy-policy-upcoming';
139+
$alert->type = 'warning';
140+
$alert->active_from = now();
141+
$alert->active_until = $upcomingPolicy->valid_at;
142+
143+
$translation = new AlertTranslation();
144+
$translation->locale = app()->getLocale();
145+
$translation->title = __('privacy.upcoming-alert.title');
146+
$translation->content = __('privacy.upcoming-alert.content', [
147+
'date' => $upcomingPolicy->valid_at->isoFormat('LL'),
148+
]);
149+
$translation->url = url('/gdpr-intercept');
150+
$alert->setRelation('translations', collect([$translation]));
151+
152+
$alerts->prepend($alert);
153+
}
154+
128155
return AlertResource::collection($alerts);
129156
}
130157

app/Http/Resources/PrivacyPolicyResource.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@
5353
type: 'boolean',
5454
example: false,
5555
),
56+
new OA\Property(
57+
property: 'upcoming',
58+
description: 'Next privacy policy that is not yet in effect, if any.',
59+
nullable: true,
60+
properties: [
61+
new OA\Property(property: 'id', type: 'string', format: 'uuid', example: '00000000-0000-0000-0000-000000000000'),
62+
new OA\Property(property: 'validFrom', type: 'string', format: 'date-time', example: '2022-01-05T16:26:14.000000Z'),
63+
new OA\Property(property: 'en', type: 'string', example: 'This is the english privacy policy'),
64+
new OA\Property(property: 'de', type: 'string', example: 'Dies ist die deutsche Datenschutzerklärung'),
65+
new OA\Property(property: 'acceptedAt', type: 'string', format: 'date-time', nullable: true, example: null),
66+
],
67+
type: 'object',
68+
),
5669
],
5770
type: 'object'
5871
)]
@@ -73,6 +86,13 @@ public function toArray(Request $request): array
7386
'de' => $this->policy->body_md_de,
7487
'acceptedAt' => $this->acceptedAt,
7588
'hasOldAcceptance' => $this->hasOldAcceptance,
89+
'upcoming' => $this->upcomingPolicy !== null ? [
90+
'id' => $this->upcomingPolicy->id,
91+
'validFrom' => $this->upcomingPolicy->valid_at,
92+
'en' => $this->upcomingPolicy->body_md_en,
93+
'de' => $this->upcomingPolicy->body_md_de,
94+
'acceptedAt' => $this->upcomingAcceptedAt,
95+
] : null,
7696
];
7797
}
7898
}

app/Repositories/PrivacyPolicyRepository.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ public function getPrivacyPolicyValidAt(CarbonInterface $validAt): PrivacyPolicy
1717
->first();
1818
}
1919

20+
public function getUpcomingPrivacyPolicy(): ?PrivacyPolicy
21+
{
22+
return PrivacyPolicy::where('valid_at', '>', now()->toIso8601ZuluString())
23+
->orderBy('valid_at')
24+
->first();
25+
}
26+
2027
public function getPrivacyPolicyById(string $id): PrivacyPolicy
2128
{
2229
return PrivacyPolicy::whereId($id)->firstOrFail();

app/Services/PrivacyPolicyService.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ public function hasUserAcceptedPolicy(User $user, ?PrivacyPolicy $policy = null)
7676
return false;
7777
}
7878

79+
public function getUpcomingPolicy(): ?PrivacyPolicy
80+
{
81+
return $this->repository->getUpcomingPrivacyPolicy();
82+
}
83+
7984
public function getLastAcceptedPolicy(User $user): ?PrivacyPolicyAcceptance
8085
{
8186
return $this->repository->getLastAcceptedPolicy($user);
@@ -89,14 +94,21 @@ public function getPolicyWithAcceptanceStatus(?User $user): PrivacyPolicyWithAcc
8994
$policy = $this->getPrivacyPolicy();
9095
$acceptedAt = null;
9196
$hasOldAcceptance = false;
97+
$upcomingPolicy = $this->repository->getUpcomingPrivacyPolicy();
98+
$upcomingAcceptedAt = null;
9299

93100
if ($user !== null) {
94101
$allAcceptances = $this->getUserAcceptance($user);
95102
$ownAcceptance = $allAcceptances->firstWhere('privacy_policy_id', $policy->id);
96103
$acceptedAt = $ownAcceptance?->accepted_at;
97104
$hasOldAcceptance = $acceptedAt === null && $allAcceptances->isNotEmpty();
105+
106+
if ($upcomingPolicy !== null) {
107+
$upcomingAcceptance = $allAcceptances->firstWhere('privacy_policy_id', $upcomingPolicy->id);
108+
$upcomingAcceptedAt = $upcomingAcceptance?->accepted_at;
109+
}
98110
}
99111

100-
return new PrivacyPolicyWithAcceptance($policy, $acceptedAt, $hasOldAcceptance);
112+
return new PrivacyPolicyWithAcceptance($policy, $acceptedAt, $hasOldAcceptance, $upcomingPolicy, $upcomingAcceptedAt);
101113
}
102114
}

lang/de.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,11 +540,18 @@
540540
"passwords.token": "Dieser Link zum Zurücksetzen eines Passwortes ist ungültig.",
541541
"passwords.user": "Wir können keinen Benutzer mit dieser E-Mail-Adresse finden.",
542542
"platform": "Gleis",
543-
"privacy.not-signed-yet": "<b>Du hast unseren Datenschutzbedingungen noch nicht zugestimmt.</b> Dies wird jedoch benötigt, um Träwelling zu verwenden. Falls Du nicht zustimmen solltest, kannst Du auch an dieser Stelle Deinen Account löschen.",
543+
"privacy.upcoming-alert.content": "Ab dem :date gelten neue Datenschutzbedingungen. Du kannst ihnen bereits jetzt zustimmen.",
544+
"privacy.upcoming-alert.title": "Neue Datenschutzbedingungen",
545+
"privacy.upcoming.public-notice": "Ab dem :date gelten neue Datenschutzbedingungen.",
546+
"privacy.upcoming.public-notice-link": "Jetzt ansehen",
547+
"privacy.upcoming.section-heading": "Neue Datenschutzbedingungen (gültig ab :date)",
548+
"privacy.not-signed-yet.body": "Dies wird jedoch benötigt, um Träwelling zu verwenden. Falls Du nicht zustimmen solltest, kannst Du auch an dieser Stelle Deinen Account löschen.",
549+
"privacy.not-signed-yet.title": "Du hast unseren Datenschutzbedingungen noch nicht zugestimmt.",
544550
"privacy.sign": "Okay",
545551
"privacy.sign.more": "Akzeptieren & weiter zu Träwelling",
546552
"privacy.title": "Datenschutz&shy;beding&shy;ungen",
547-
"privacy.we-changed": "<b>Wir haben unsere Datenschutzbedingungen geändert.</b> Um diesen Service weiterhin nutzen zu können, musst Du den neuen Bedingungen zustimmen. Falls Du nicht zustimmen solltest, kannst Du auch an dieser Stelle Deinen Account löschen.",
553+
"privacy.we-changed.body": "Um diesen Service weiterhin nutzen zu können, musst Du den neuen Bedingungen zustimmen. Falls Du nicht zustimmen solltest, kannst Du auch an dieser Stelle Deinen Account löschen.",
554+
"privacy.we-changed.title": "Wir haben unsere Datenschutzbedingungen geändert.",
548555
"profile.bio": "Bio",
549556
"profile.bio.description": "Kurze Beschreibung über dich, die auf deinem Profil angezeigt wird.",
550557
"profile.follow": "Folgen",

lang/en.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,11 +540,18 @@
540540
"passwords.token": "This password reset token is invalid.",
541541
"passwords.user": "We can't find a user with that e-mail address.",
542542
"platform": "Platform",
543-
"privacy.not-signed-yet": "<b>You have not signed our privacy policy yet.</b> To use Träwelling, you'll need to agree to our privacy policy. If you don't wish to agree, you can delete your account by clicking on the button below.",
543+
"privacy.upcoming-alert.content": "A new privacy policy will take effect on :date. You can accept it early.",
544+
"privacy.upcoming-alert.title": "New Privacy Policy",
545+
"privacy.upcoming.public-notice": "A new privacy policy will take effect on :date.",
546+
"privacy.upcoming.public-notice-link": "Preview below",
547+
"privacy.upcoming.section-heading": "New Privacy Policy (effective :date)",
548+
"privacy.not-signed-yet.body": "To use Träwelling, you'll need to agree to our privacy policy. If you don't wish to agree, you can delete your account by clicking on the button below.",
549+
"privacy.not-signed-yet.title": "You have not signed our privacy policy yet.",
544550
"privacy.sign": "Sign",
545551
"privacy.sign.more": "Accept & continue to traewelling",
546552
"privacy.title": "Privacy Policy",
547-
"privacy.we-changed": "<b>We changed our privacy policy.</b> To continue using our service, you have to agree to the latest changes. If you don't wish to agree, you can delete your account by clicking on the button below.",
553+
"privacy.we-changed.body": "To continue using our service, you have to agree to the latest changes. If you don't wish to agree, you can delete your account by clicking on the button below.",
554+
"privacy.we-changed.title": "We changed our privacy policy.",
548555
"profile.bio": "Bio",
549556
"profile.bio.description": "A short description about you, shown on your public profile.",
550557
"profile.follow": "Follow",

lang/fr.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,11 +506,18 @@
506506
"passwords.token": "Ce lien pour réinitialiser un mot de passe n’est pas valide.",
507507
"passwords.user": "Nous n’avons pas pu trouver d’utilisateur·rice avec cette adresse e-mail.",
508508
"platform": "Voie",
509-
"privacy.not-signed-yet": "<b>Tu n'as pas encore signé notre politique de confidentialité.</b> Pour utiliser Träwelling, tu dois accepter notre politique de confidentialité. Si tu ne souhaites pas accepter cette politique, tu peux supprimer ton compte en cliquant sur le bouton ci-dessous.",
509+
"privacy.upcoming-alert.content": "Une nouvelle politique de confidentialité entrera en vigueur le :date. Tu peux l'accepter dès maintenant.",
510+
"privacy.upcoming-alert.title": "Nouvelle politique de confidentialité",
511+
"privacy.upcoming.public-notice": "Une nouvelle politique de confidentialité entrera en vigueur le :date.",
512+
"privacy.upcoming.public-notice-link": "Voir ci-dessous",
513+
"privacy.upcoming.section-heading": "Nouvelle politique de confidentialité (en vigueur le :date)",
514+
"privacy.not-signed-yet.body": "Pour utiliser Träwelling, tu dois accepter notre politique de confidentialité. Si tu ne souhaites pas accepter cette politique, tu peux supprimer ton compte en cliquant sur le bouton ci-dessous.",
515+
"privacy.not-signed-yet.title": "Tu n'as pas encore signé notre politique de confidentialité.",
510516
"privacy.sign": "Signer",
511517
"privacy.sign.more": "Accepter et continuer sur Träwelling",
512518
"privacy.title": "Politique de confidentialité",
513-
"privacy.we-changed": "<b>Nous avons modifié notre politique de confidentialité</b> Pour continuer à utiliser notre service, tu dois accepter les dernières modifications. Si tu ne souhaites pas accepter, tu peux supprimer ton compte en cliquant sur le bouton ci-dessous.",
519+
"privacy.we-changed.body": "Pour continuer à utiliser notre service, tu dois accepter les dernières modifications. Si tu ne souhaites pas accepter, tu peux supprimer ton compte en cliquant sur le bouton ci-dessous.",
520+
"privacy.we-changed.title": "Nous avons modifié notre politique de confidentialité.",
514521
"profile.bio": "Biographie",
515522
"profile.bio.description": "Une courte description sur toi, affichée sur ton profil public.",
516523
"profile.follow": "S'abonner",

resources/types/Api.gen.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,28 @@ export interface PrivacyPolicy {
16221622
* @example false
16231623
*/
16241624
hasOldAcceptance: boolean;
1625+
/** Next privacy policy that is not yet in effect, if any. */
1626+
upcoming?: {
1627+
/**
1628+
* @format uuid
1629+
* @example "00000000-0000-0000-0000-000000000000"
1630+
*/
1631+
id?: string;
1632+
/**
1633+
* @format date-time
1634+
* @example "2022-01-05T16:26:14.000000Z"
1635+
*/
1636+
validFrom?: string;
1637+
/** @example "This is the english privacy policy" */
1638+
en?: string;
1639+
/** @example "Dies ist die deutsche Datenschutzerklärung" */
1640+
de?: string;
1641+
/**
1642+
* @format date-time
1643+
* @example null
1644+
*/
1645+
acceptedAt?: string | null;
1646+
} | null;
16251647
}
16261648

16271649
/**
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<script setup lang="ts">
2+
import { trans } from 'laravel-vue-i18n';
3+
import { ref } from 'vue';
4+
import { Api } from '../../../types/Api.gen';
5+
6+
const props = defineProps<{
7+
username: string;
8+
}>();
9+
10+
const api = new Api({ baseUrl: window.location.origin + '/api/v1' });
11+
12+
const modal = ref<HTMLDialogElement>();
13+
const deleteStep = ref<1 | 2>(1);
14+
const confirmation = ref('');
15+
const loadingDelete = ref(false);
16+
17+
function open() {
18+
deleteStep.value = 1;
19+
confirmation.value = '';
20+
modal.value?.showModal();
21+
}
22+
23+
function close() {
24+
modal.value?.close();
25+
confirmation.value = '';
26+
deleteStep.value = 1;
27+
}
28+
29+
async function deleteAccount() {
30+
loadingDelete.value = true;
31+
try {
32+
const response = await api.settings.deleteUserAccount({ confirmation: confirmation.value });
33+
if (response.ok) {
34+
window.notyf.success(trans('settings.delete-account-completed'));
35+
window.location.href = '/';
36+
} else {
37+
window.notyf.error(trans('settings.something-wrong'));
38+
close();
39+
}
40+
} catch {
41+
window.notyf.error(trans('settings.something-wrong'));
42+
close();
43+
} finally {
44+
loadingDelete.value = false;
45+
}
46+
}
47+
48+
defineExpose({ open });
49+
</script>
50+
51+
<template>
52+
<dialog
53+
ref="modal"
54+
style="z-index: 1100; border: none; border-radius: 0.5rem; padding: 0; max-width: 500px; width: 90%"
55+
>
56+
<!-- Step 1: Initial warning -->
57+
<div v-if="deleteStep === 1" style="padding: 1.5rem">
58+
<h5 class="mb-2 fw-bold">{{ trans('settings.delete-account') }}</h5>
59+
<p class="text-muted small">{{ trans('settings.delete-account.detail') }}</p>
60+
<div class="d-flex justify-content-end gap-2 mt-3">
61+
<button type="button" class="btn btn-secondary" @click="close">
62+
{{ trans('menu.abort') }}
63+
</button>
64+
<button type="button" class="btn btn-danger" @click="deleteStep = 2">
65+
{{ trans('settings.delete-account') }}
66+
</button>
67+
</div>
68+
</div>
69+
70+
<!-- Step 2: Final confirmation with username -->
71+
<div v-else style="padding: 1.5rem; border: 2px solid #dc3545; border-radius: 0.5rem">
72+
<h5 class="mb-2 fw-bold text-danger">{{ trans('settings.delete-account') }}</h5>
73+
<!-- eslint-disable-next-line vue/no-v-html -->
74+
<p class="small" v-html="trans('settings.delete-account-verify', { appname: 'Träwelling' })"></p>
75+
<form @submit.prevent="deleteAccount">
76+
<div class="mt-3">
77+
<!-- eslint-disable vue/no-v-html -->
78+
<label
79+
class="form-label small"
80+
v-html="trans('messages.account.please-confirm', { delete: props.username })"
81+
></label>
82+
<!-- eslint-enable vue/no-v-html -->
83+
<input
84+
v-model="confirmation"
85+
type="text"
86+
class="form-control is-invalid"
87+
:placeholder="props.username ?? ''"
88+
autocomplete="off"
89+
required
90+
/>
91+
</div>
92+
<div class="d-flex justify-content-end gap-2 mt-3">
93+
<button type="button" class="btn btn-secondary" @click="deleteStep = 1">
94+
{{ trans('settings.delete-account-btn-back') }}
95+
</button>
96+
<button
97+
class="btn btn-danger"
98+
type="submit"
99+
:disabled="loadingDelete || confirmation !== props.username"
100+
>
101+
{{ trans('settings.delete-account-btn-confirm') }}
102+
</button>
103+
</div>
104+
</form>
105+
</div>
106+
107+
<form method="dialog" style="display: none">
108+
<button>close</button>
109+
</form>
110+
</dialog>
111+
</template>

0 commit comments

Comments
 (0)