diff --git a/internal/api/me.go b/internal/api/me.go
new file mode 100644
index 000000000..e58574df6
--- /dev/null
+++ b/internal/api/me.go
@@ -0,0 +1,49 @@
+package api
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/hashicorp/go-hclog"
+)
+
+func MeHandler(
+ l hclog.Logger,
+) http.Handler {
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ errResp := func(httpCode int, userErrMsg, logErrMsg string, err error) {
+ l.Error(logErrMsg,
+ "method", r.Method,
+ "path", r.URL.Path,
+ "error", err,
+ )
+ errJSON := fmt.Sprintf(`{"error": "%s"}`, userErrMsg)
+ http.Error(w, errJSON, httpCode)
+ }
+
+ // Authorize request.
+ userEmail := r.Context().Value("userEmail").(string)
+ if userEmail == "" {
+ errResp(
+ http.StatusUnauthorized,
+ "No authorization information for request",
+ "no user email found in request context",
+ nil,
+ )
+ return
+ }
+
+ switch r.Method {
+ // The HEAD method is used to determine if the user is currently
+ // authenticated.
+ case "HEAD":
+ w.WriteHeader(http.StatusOK)
+ return
+
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ })
+}
diff --git a/internal/cmd/commands/server/server.go b/internal/cmd/commands/server/server.go
index 0f1eb6e90..c18375b1e 100644
--- a/internal/cmd/commands/server/server.go
+++ b/internal/cmd/commands/server/server.go
@@ -266,6 +266,7 @@ func (c *Command) Run(args []string) int {
api.DraftsHandler(cfg, c.Log, algoSearch, algoWrite, goog, db)},
{"/api/v1/drafts/",
api.DraftsDocumentHandler(cfg, c.Log, algoSearch, algoWrite, goog, db)},
+ {"/api/v1/me", api.MeHandler(c.Log)},
{"/api/v1/me/subscriptions",
api.MeSubscriptionsHandler(cfg, c.Log, goog, db)},
{"/api/v1/people", api.PeopleDataHandler(cfg, c.Log, goog)},
diff --git a/web/app/components/notification.hbs b/web/app/components/notification.hbs
index b40f151e4..f428f9d58 100644
--- a/web/app/components/notification.hbs
+++ b/web/app/components/notification.hbs
@@ -1,14 +1,31 @@
{{#each this.flashMessages.queue as |flash|}}
-
+
- {{flash.title}}
- {{flash.message}}
+
+ {{flash.title}}
+
+
+ {{flash.message}}
+
+ {{#if (and flash.buttonText flash.buttonAction)}}
+
+ {{/if}}
{{/each}}
diff --git a/web/app/components/notification.js b/web/app/components/notification.js
deleted file mode 100644
index 518a0f769..000000000
--- a/web/app/components/notification.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Component from '@glimmer/component';
-import { inject as service } from '@ember/service';
-import { action } from "@ember/object";
-
-export default class Notification extends Component {
- @service flashMessages;
-
- @action
- dismiss() {
- // Left empty as flash messages disappear on click
- }
-}
diff --git a/web/app/components/notification.ts b/web/app/components/notification.ts
new file mode 100644
index 000000000..a2c1ed953
--- /dev/null
+++ b/web/app/components/notification.ts
@@ -0,0 +1,7 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import FlashMessageService from "ember-cli-flash/services/flash-messages";
+
+export default class Notification extends Component {
+ @service declare flashMessages: FlashMessageService;
+}
diff --git a/web/app/routes/authenticated.ts b/web/app/routes/authenticated.ts
index a9df67cf7..8dd438b7a 100644
--- a/web/app/routes/authenticated.ts
+++ b/web/app/routes/authenticated.ts
@@ -10,6 +10,7 @@ export default class AuthenticatedRoute extends Route {
async afterModel(): Promise {
await this.authenticatedUser.loadInfo.perform();
+ void this.session.pollForExpiredAuth.perform();
}
async beforeModel(transition: any): Promise {
diff --git a/web/app/services/fetch.ts b/web/app/services/fetch.ts
index 39172d1eb..fff0a02f0 100644
--- a/web/app/services/fetch.ts
+++ b/web/app/services/fetch.ts
@@ -14,7 +14,7 @@ interface FetchOptions {
export default class FetchService extends Service {
@service declare session: SessionService;
- async fetch(url: string, options: FetchOptions = {}) {
+ async fetch(url: string, options: FetchOptions = {}, isPollCall = false) {
// Add the Google access token in a header (for auth) if the URL starts with
// a frontslash, which will only target the application backend.
if (Array.from(url)[0] == "/") {
@@ -27,7 +27,20 @@ export default class FetchService extends Service {
try {
const resp = await fetch(url, options);
+ // if it's a poll call, tell the SessionService if the response was a 401
+ if (isPollCall) {
+ this.session.pollResponseIs401 = resp.status === 401;
+ }
+
if (!resp.ok) {
+ if (resp.status === 401) {
+ if (isPollCall) {
+ // handle poll-call failures via the session service
+ return;
+ } else {
+ this.session.invalidate();
+ }
+ }
throw new Error(`Bad response: ${resp.statusText}`);
}
diff --git a/web/app/services/session.ts b/web/app/services/session.ts
index ce2538e7a..7ad2ec4d1 100644
--- a/web/app/services/session.ts
+++ b/web/app/services/session.ts
@@ -2,6 +2,12 @@ import { inject as service } from "@ember/service";
import RouterService from "@ember/routing/router-service";
import EmberSimpleAuthSessionService from "ember-simple-auth/services/session";
import window from "ember-window-mock";
+import { keepLatestTask } from "ember-concurrency";
+import FlashMessageService from "ember-cli-flash/services/flash-messages";
+import Ember from "ember";
+import { tracked } from "@glimmer/tracking";
+import simpleTimeout from "hermes/utils/simple-timeout";
+import FetchService from "./fetch";
export const REDIRECT_STORAGE_KEY = "hermes.redirectTarget";
@@ -16,6 +22,75 @@ export function isJSON(str: string) {
export default class SessionService extends EmberSimpleAuthSessionService {
@service declare router: RouterService;
+ @service declare fetch: FetchService;
+ @service declare session: SessionService;
+ @service declare flashMessages: FlashMessageService;
+
+ /**
+ * Whether the current session is valid.
+ * Set false if our poll response is 401, and when the
+ * user requires authentication with EmberSimpleAuth.
+ */
+ @tracked tokenIsValid = true;
+
+ /**
+ * Whether the service should show a reauthentication message.
+ * True when the user has dismissed a previous re-auth message.
+ */
+ @tracked preventReauthenticationMessage = false;
+
+ /**
+ * Whether the last poll response was a 401.
+ * Updated by the fetch service on every pollCall.
+ */
+ @tracked pollResponseIs401 = false;
+
+ /**
+ * A persistent task that periodically checks if the user's
+ * session has expired, and shows a flash message if it has.
+ * Kicked off by the Authenticated route.
+ */
+ pollForExpiredAuth = keepLatestTask(async () => {
+ await simpleTimeout(Ember.testing ? 100 : 10000);
+
+ this.fetch.fetch(
+ "/api/v1/me",
+ {
+ method: "HEAD",
+ },
+ true
+ );
+
+ let isLoggedIn = await this.requireAuthentication(null, () => {});
+
+ if (this.pollResponseIs401 || !isLoggedIn) {
+ this.tokenIsValid = false;
+ }
+
+ if (this.tokenIsValid) {
+ this.preventReauthenticationMessage = false;
+ } else if (!this.preventReauthenticationMessage) {
+ this.flashMessages.add({
+ title: "Login token expired",
+ message: "Please reauthenticate to keep using Hermes.",
+ type: "warning",
+ sticky: true,
+ destroyOnClick: false,
+ preventDuplicates: true,
+ buttonText: "Authenticate with Google",
+ buttonIcon: "google",
+ buttonAction: () => {
+ this.authenticate("authenticator:torii", "google-oauth2-bearer");
+ this.flashMessages.clearMessages();
+ },
+ onDestroy: () => {
+ this.preventReauthenticationMessage = true;
+ },
+ });
+ }
+
+ this.pollForExpiredAuth.perform();
+ });
// ember-simple-auth only uses a cookie to track redirect target if you're using fastboot, otherwise it keeps track of the redirect target as a parameter on the session service. See the source here: https://github.com/mainmatter/ember-simple-auth/blob/a7e583cf4d04d6ebc96b198a8fa6dde7445abf0e/packages/ember-simple-auth/addon/-internals/routing.js#L33-L50
//
diff --git a/web/app/styles/components/notification.scss b/web/app/styles/components/notification.scss
index a8e0c27f7..e3a1c5d76 100644
--- a/web/app/styles/components/notification.scss
+++ b/web/app/styles/components/notification.scss
@@ -1,24 +1,24 @@
.notifications-container {
- @apply fixed;
- z-index: 20;
- bottom: 8px;
- right: 8px;
+ @apply fixed z-20 bottom-6 right-6;
}
-.notification {
- // Animation example taken from: https://github.com/adopted-ember-addons/ember-cli-flash#animated-example
- opacity: 0;
-
- transition: all 700ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
- margin: 16px;
-
- &.active {
+@keyframes notificationIn {
+ from {
+ opacity: 0;
+ transform: translateX(8px);
+ }
+ to {
opacity: 1;
- @apply left-2;
+ transform: translateX(0);
+ }
+}
+
+.notification {
+ animation: notificationIn 700ms cubic-bezier(0.68, -0.55, 0.265, 1.55)
+ forwards;
- &.exiting {
- opacity: 0;
- @apply left-0;
- }
+ &.exiting {
+ animation: notificationIn 300ms cubic-bezier(0.68, -0.55, 0.265, 1.55)
+ reverse forwards;
}
}
diff --git a/web/app/utils/simple-timeout.ts b/web/app/utils/simple-timeout.ts
new file mode 100644
index 000000000..4ce4c877a
--- /dev/null
+++ b/web/app/utils/simple-timeout.ts
@@ -0,0 +1,14 @@
+/**
+ * A timeout function for polling tasks.
+ * Not registered with Ember's runloop
+ * (unlike ember-concurrency's timeout helper),
+ * so it doesn't hang in acceptance tests.
+ *
+ * See: https://ember-concurrency.com/docs/testing-debugging
+ */
+
+export default function simpleTimeout(timeout: number) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, timeout);
+ });
+}
diff --git a/web/mirage/config.ts b/web/mirage/config.ts
index 181063b8a..5bb606424 100644
--- a/web/mirage/config.ts
+++ b/web/mirage/config.ts
@@ -10,6 +10,22 @@ export default function (mirageConfig) {
routes() {
this.namespace = "api/v1";
+ /*************************************************************************
+ *
+ * HEAD requests
+ *
+ *************************************************************************/
+
+ this.head("/me", (schema, _request) => {
+ let isLoggedIn = schema.db.mes[0].isLoggedIn;
+
+ if (isLoggedIn) {
+ return new Response(200, {});
+ } else {
+ return new Response(401, {});
+ }
+ });
+
/*************************************************************************
*
* POST requests
diff --git a/web/tests/acceptance/application-test.ts b/web/tests/acceptance/application-test.ts
new file mode 100644
index 000000000..f62892845
--- /dev/null
+++ b/web/tests/acceptance/application-test.ts
@@ -0,0 +1,117 @@
+import { click, teardownContext, visit, waitFor } from "@ember/test-helpers";
+import { setupApplicationTest } from "ember-qunit";
+import { module, test } from "qunit";
+import { authenticateSession } from "ember-simple-auth/test-support";
+import { MirageTestContext, setupMirage } from "ember-cli-mirage/test-support";
+import SessionService from "hermes/services/session";
+
+module("Acceptance | application", function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ interface ApplicationTestContext extends MirageTestContext {
+ session: SessionService;
+ }
+
+ hooks.beforeEach(function () {
+ this.set("session", this.owner.lookup("service:session"));
+ });
+
+ test("a message shows when the front-end auth token expires", async function (this: ApplicationTestContext, assert) {
+ await authenticateSession({});
+
+ await visit("/");
+
+ assert
+ .dom("[data-test-flash-notification]")
+ .doesNotExist("no flash notification when session is valid");
+
+ await this.session.invalidate();
+
+ await waitFor("[data-test-flash-notification]");
+
+ assert
+ .dom("[data-test-flash-notification]")
+ .exists("flash notification is shown when session is invalid");
+
+ assert
+ .dom("[data-test-flash-notification-title]")
+ .hasText("Login token expired");
+
+ assert
+ .dom("[data-test-flash-notification-description]")
+ .hasText("Please reauthenticate to keep using Hermes.");
+
+ assert
+ .dom("[data-test-flash-notification-button]")
+ .hasText("Authenticate with Google");
+ /**
+ * FIXME: Investigate unresolved promises
+ *
+ * For reasons not yet clear, this test has unresolved promises
+ * that prevent it from completing naturally. Because of this,
+ * we handle teardown manually.
+ *
+ */
+ teardownContext(this);
+ });
+
+ test("a message shows when the back-end auth token expires", async function (this: ApplicationTestContext, assert) {
+ await authenticateSession({});
+
+ await visit("/");
+
+ assert
+ .dom("[data-test-flash-notification]")
+ .doesNotExist("no flash notification when session is valid");
+
+ this.server.db.mes[0] = this.server.create("me", { isLoggedIn: false });
+
+ await waitFor("[data-test-flash-notification]");
+
+ assert
+ .dom("[data-test-flash-notification]")
+ .exists("flash notification is shown when session is invalid");
+
+ /**
+ * FIXME: Investigate unresolved promises
+ *
+ * For reasons not yet clear, this test has unresolved promises
+ * that prevent it from completing naturally. Because of this,
+ * we handle teardown manually.
+ *
+ */
+ teardownContext(this);
+ });
+
+ test("the authorize button works as expected", async function (this: ApplicationTestContext, assert) {
+ let authCount = 0;
+
+ await authenticateSession({});
+
+ this.session.authenticate = () => {
+ authCount++;
+ };
+
+ await visit("/");
+ await this.session.invalidate();
+ await waitFor("[data-test-flash-notification]");
+ await click("[data-test-flash-notification-button]");
+
+ assert
+ .dom("[data-test-flash-notification]")
+ .doesNotExist("flash notification is dismissed on buttonClick");
+
+ assert.equal(authCount, 1, "session.authenticate() was called");
+
+ /**
+ * FIXME: Investigate unresolved promises
+ *
+ * For reasons not yet clear, this test has unresolved promises
+ * that prevent it from completing naturally. Because of this,
+ * we handle teardown manually.
+ *
+ */
+ teardownContext(this);
+ });
+});
diff --git a/web/types/ember-simple-auth/services/index.d.ts b/web/types/ember-simple-auth/services/index.d.ts
index 6084465b7..dc4c4e47a 100644
--- a/web/types/ember-simple-auth/services/index.d.ts
+++ b/web/types/ember-simple-auth/services/index.d.ts
@@ -1,4 +1,7 @@
-declare module 'ember-simple-auth/test-support' {
- import { SessionAuthenticatedData } from 'ember-simple-auth/services/session';
- export function authenticateSession(responseFromApi: SessionAuthenticatedData): void;
+declare module "ember-simple-auth/test-support" {
+ import { SessionAuthenticatedData } from "ember-simple-auth/services/session";
+ export function authenticateSession(
+ responseFromApi: SessionAuthenticatedData
+ ): Promise;
+ export function invalidateSession(): Promise;
}
diff --git a/web/types/ember-simple-auth/services/session.d.ts b/web/types/ember-simple-auth/services/session.d.ts
index c45ce6384..f03513ba0 100644
--- a/web/types/ember-simple-auth/services/session.d.ts
+++ b/web/types/ember-simple-auth/services/session.d.ts
@@ -17,7 +17,7 @@ declare module "ember-simple-auth/services/session" {
authenticate(...args: any[]): RSVP.Promise;
invalidate(...args: any): RSVP.Promise;
requireAuthentication(
- transition: Transition,
+ transition: Transition | null,
routeOrCallback: string | function
): RSVP.Promise;
prohibitAuthentication(routeOrCallback: string | function): RSVP.Promise;