Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Privacy policy update alert #18852

Merged
merged 14 commits into from
Oct 17, 2023
143 changes: 143 additions & 0 deletions components/dashboard/src/AppNotifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { useCallback, useEffect, useState } from "react";
import Alert, { AlertType } from "./components/Alert";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import advancedFormat from "dayjs/plugin/advancedFormat";
import { useUserLoader } from "./hooks/use-user-loader";
import { getGitpodService } from "./service/service";
import { deepMerge } from "./utils";

const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed";
const PRIVACY_POLICY_LAST_UPDATED = "2023-09-26";

interface Notification {
id: string;
type: AlertType;
message: JSX.Element;
preventDismiss?: boolean;
onClose?: () => void;
}

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);

export function localizedTime(dateStr: string): JSX.Element {
const formatted = dayjs.utc(dateStr).local().format("dddd, MMM. D, HH:mm (z)");
filiptronicek marked this conversation as resolved.
Show resolved Hide resolved
return <time dateTime={dateStr}>{formatted}</time>;
}

export function formatDate(dateString: string): JSX.Element {
const formatted = dayjs.utc(dateString).local().format("MMMM D, YYYY");
filiptronicek marked this conversation as resolved.
Show resolved Hide resolved
return <time dateTime={dateString}>{formatted}</time>;
}

const UPDATED_PRIVACY_POLICY: Notification = {
id: "privacy-policy-update",
type: "info",
preventDismiss: true,
onClose: async () => {
const userUpdates = { additionalData: { profile: { acceptedPrivacyPolicyDate: dayjs().toISOString() } } };
const previousUser = await getGitpodService().server.getLoggedInUser();
await getGitpodService().server.updateLoggedInUser(deepMerge(previousUser, userUpdates));
},
message: (
<span className="text-md">
We've updated our Privacy Policy. You can review it{" "}
<a className="gp-link" href="https://www.gitpod.io/privacy" target="_blank" rel="noreferrer">
here
</a>
.
</span>
),
};

export function AppNotifications() {
const [topNotification, setTopNotification] = useState<Notification | undefined>(undefined);
const { user, loading } = useUserLoader();

useEffect(() => {
const notifications = [];
if (!loading && user?.additionalData?.profile) {
if (
!user.additionalData.profile.acceptedPrivacyPolicyDate ||
new Date(PRIVACY_POLICY_LAST_UPDATED) > new Date(user.additionalData.profile?.acceptedPrivacyPolicyDate)
) {
notifications.push(UPDATED_PRIVACY_POLICY);
}
}

const dismissedNotifications = getDismissedNotifications();
const topNotification = notifications.find((n) => !dismissedNotifications.includes(n.id));
setTopNotification(topNotification);
}, [loading, setTopNotification, user]);

const dismissNotification = useCallback(() => {
if (!topNotification) {
return;
}

const dismissedNotifications = getDismissedNotifications();
dismissedNotifications.push(topNotification.id);
setDismissedNotifications(dismissedNotifications);
setTopNotification(undefined);
}, [topNotification, setTopNotification]);

if (!topNotification) {
return <></>;
}

return (
<div className="app-container pt-2">
<Alert
type={topNotification.type}
closable={true}
onClose={() => {
if (!topNotification.preventDismiss) {
dismissNotification();
} else {
if (topNotification.onClose) {
topNotification.onClose();
}
}
}}
showIcon={true}
className="flex rounded mb-2 w-full"
>
<span>{topNotification.message}</span>
</Alert>
</div>
);
}

function getDismissedNotifications(): string[] {
try {
const str = window.localStorage.getItem(KEY_APP_DISMISSED_NOTIFICATIONS);
const parsed = JSON.parse(str || "[]");
if (!Array.isArray(parsed)) {
window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);
return [];
}
return parsed;
} catch (err) {
console.debug("Failed to parse dismissed notifications", err);
window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);
return [];
}
}

function setDismissedNotifications(ids: string[]) {
try {
window.localStorage.setItem(KEY_APP_DISMISSED_NOTIFICATIONS, JSON.stringify(ids));
} catch (err) {
console.debug("Failed to set dismissed notifications", err);
window.localStorage.removeItem(KEY_APP_DISMISSED_NOTIFICATIONS);
}
}
2 changes: 2 additions & 0 deletions components/dashboard/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import PersonalAccessTokenCreateView from "../user-settings/PersonalAccessTokens
import { CreateWorkspacePage } from "../workspaces/CreateWorkspacePage";
import { WebsocketClients } from "./WebsocketClients";
import { BlockedEmailDomains } from "../admin/BlockedEmailDomains";
import { AppNotifications } from "../AppNotifications";

const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "../workspaces/Workspaces"));
const Account = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/Account"));
Expand Down Expand Up @@ -121,6 +122,7 @@ export const AppRoutes = () => {
<Route>
<div className="container">
<Menu />
<AppNotifications />
<Switch>
<Route path="/new" exact component={CreateWorkspacePage} />
<Route path={projectsPathNew} exact component={NewProject} />
Expand Down
4 changes: 2 additions & 2 deletions components/dashboard/src/components/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export default function Alert(props: AlertProps) {
return (
<div
className={classNames(
"flex relative whitespace-pre-wrap p-4",
"flex relative items-center whitespace-pre-wrap p-4",
info.txtCls,
props.className,
light ? "" : info.bgCls,
Expand All @@ -121,7 +121,7 @@ export default function Alert(props: AlertProps) {
{showIcon && <span className={`mt-1 mr-4 h-4 w-4 ${info.iconColor}`}>{props.icon ?? info.icon}</span>}
<span className="flex-1 text-left">{props.children}</span>
{props.closable && (
<span className={`mt-1 ml-4`}>
<span className="ml-4">
{/* Use an IconButton component once we make it */}
<Button
type="secondary"
Expand Down
25 changes: 25 additions & 0 deletions components/dashboard/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ export function getURLHash() {
return window.location.hash.replace(/^[#/]+/, "");
}

export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]> | T[P];
};

function isObject(item: any): item is Record<string, any> {
return item && typeof item === "object" && !Array.isArray(item);
}

export function deepMerge<T>(target: T, source: DeepPartial<T>): T {
filiptronicek marked this conversation as resolved.
Show resolved Hide resolved
for (let key in source) {
if (source.hasOwnProperty(key)) {
const currentKey = key as keyof T;
if (isObject(source[currentKey]) && isObject(target[currentKey])) {
target[currentKey] = deepMerge(
target[currentKey],
source[currentKey] as DeepPartial<T[keyof T]>,
) as T[keyof T];
} else {
target[currentKey] = source[currentKey] as T[keyof T];
}
}
}
return target;
}

export function isWebsiteSlug(pathName: string) {
const slugs = [
"about",
Expand Down
2 changes: 2 additions & 0 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ export namespace AdditionalUserData {
export interface ProfileDetails {
// when was the last time the user updated their profile information or has been nudged to do so.
lastUpdatedDetailsNudge?: string;
// when was the last time the user has accepted our privacy policy
acceptedPrivacyPolicyDate?: string;
// the user's company name
companyName?: string;
// the user's email
Expand Down
4 changes: 4 additions & 0 deletions components/server/src/user/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export class UserService {
// blocked = if user already blocked OR is not allowed to pass
newUser.blocked = newUser.blocked || !canPass;
}
if (newUser.additionalData) {
// When a user is created, it does not have `additionalData.profile` set, so it's ok to rewrite it here.
// newUser.additionalData.profile = { acceptedPrivacyPolicyDate: new Date().toISOString() };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean to uncomment this or should we remove? I wonder if we need to present the privacy policy somewhere during signup if we don't already so that we could set it automatically like this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meant to uncomment 🙈. @selfcontained we have this awesome little catch on our login pages which I believe allows us to do just that.
image

}
}

async findUserById(userId: string, id: string): Promise<User> {
Expand Down
Loading