Connect OSM account
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 49s
Release Drafter / Update release notes draft (pull_request) Successful in 4s

This commit is contained in:
2026-04-01 18:35:01 +04:00
parent 2dfd411837
commit e7dfed204e
12 changed files with 482 additions and 8 deletions

View File

@@ -1,8 +1,12 @@
# This file is committed to git and should not contain any secrets.
#
#
# Vite recommends using .env.local or .env.[mode].local if you need to manage secrets
# SEE: https://vite.dev/guide/env-and-mode.html#env-files for more information.
# Default NODE_ENV with vite build --mode=test is production
NODE_ENV=development
# OpenStreetMap OAuth
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org

View File

@@ -1,9 +1,13 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
@action
connectRS() {
this.args.onClose();
@@ -15,6 +19,17 @@ export default class UserMenuComponent extends Component {
this.args.storage.disconnect();
}
@action
connectOsm() {
this.args.onClose();
this.osmAuth.login();
}
@action
disconnectOsm() {
this.osmAuth.logout();
}
<template>
<div class="user-menu-popover">
<ul class="account-list">
@@ -40,22 +55,39 @@ export default class UserMenuComponent extends Component {
</div>
<div class="account-status">
{{#if @storage.connected}}
<strong>{{@storage.userAddress}}</strong>
{{@storage.userAddress}}
{{else}}
Not connected
{{/if}}
</div>
</li>
<li class="account-item disabled">
<li class="account-item">
<div class="account-header">
<div class="account-info">
<Icon @name="map" @size={{18}} />
<span>OpenStreetMap</span>
</div>
{{#if this.osmAuth.isConnected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectOsm}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectOsm}}
>Connect</button>
{{/if}}
</div>
<div class="account-status">
Coming soon
{{#if this.osmAuth.isConnected}}
{{this.osmAuth.userDisplayName}}
{{else}}
Not connected
{{/if}}
</div>
</li>

View File

@@ -10,4 +10,7 @@ Router.map(function () {
this.route('place', { path: '/place/:place_id' });
this.route('place.new', { path: '/place/new' });
this.route('search');
this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' });
});
});

View File

@@ -0,0 +1,17 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class OauthOsmCallbackRoute extends Route {
@service osmAuth;
@service router;
async model() {
try {
await this.osmAuth.handleCallback();
} catch (e) {
console.error('Failed to handle OSM OAuth callback', e);
} finally {
this.router.transitionTo('index');
}
}
}

103
app/services/osm-auth.js Normal file
View File

@@ -0,0 +1,103 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { OAuth2AuthCodePkceClient } from 'oauth2-pkce';
class MarcoOsmAuthStorage {
saveState(serializedState) {
localStorage.setItem('marco:osm_auth_state', serializedState);
}
loadState() {
return localStorage.getItem('marco:osm_auth_state');
}
}
export default class OsmAuthService extends Service {
@tracked isConnected = false;
@tracked userDisplayName = null;
oauthClient;
constructor() {
super(...arguments);
const clientId =
import.meta.env.VITE_OSM_CLIENT_ID || 'YOUR_CLIENT_ID_HERE';
const oauthUrl =
import.meta.env.VITE_OSM_OAUTH_URL || 'https://www.openstreetmap.org';
const redirectUrl =
import.meta.env.VITE_OSM_REDIRECT_URI ||
`${window.location.origin}/oauth/osm/callback`;
this.oauthClient = new OAuth2AuthCodePkceClient(
{
scopes: ['read_prefs', 'write_api'],
authorizationUrl: `${oauthUrl}/oauth2/authorize`,
tokenUrl: `${oauthUrl}/oauth2/token`,
clientId: clientId,
redirectUrl: redirectUrl,
storeRefreshToken: true,
},
new MarcoOsmAuthStorage()
);
this.restoreSession();
}
async restoreSession() {
const isAuthorized = await this.oauthClient.isAuthorized();
if (isAuthorized) {
this.isConnected = true;
const storedName = localStorage.getItem('marco:osm_user_display_name');
if (storedName) {
this.userDisplayName = storedName;
} else {
await this.fetchUserInfo();
}
}
}
async login() {
await this.oauthClient.requestAuthorizationCode();
}
async handleCallback() {
await this.oauthClient.receiveCode();
await this.oauthClient.getTokens();
this.isConnected = true;
await this.fetchUserInfo();
}
async logout() {
await this.oauthClient.reset();
this.isConnected = false;
this.userDisplayName = null;
localStorage.removeItem('marco:osm_user_display_name');
}
async fetchUserInfo() {
try {
const tokens = await this.oauthClient.getTokens();
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(
'https://api.openstreetmap.org/api/0.6/user/details.json',
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
}
);
if (response.ok) {
const data = await response.json();
console.debug('OSM data:', data);
const displayName = data.user.display_name;
this.userDisplayName = displayName;
localStorage.setItem('marco:osm_user_display_name', displayName);
} else {
console.error('Failed to fetch OSM user info', response.status);
}
} catch (e) {
console.error('Error fetching OSM user info', e);
}
}
}

View File

@@ -246,6 +246,7 @@ body {
.account-status {
font-size: 0.85rem;
font-weight: bold;
color: #898989;
margin-top: 0.35rem;
margin-left: calc(18px + 0.75rem);

View File

@@ -104,6 +104,7 @@
"dependencies": {
"@waysidemapping/pinhead": "^15.20.0",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0"
"ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3"
}
}

8
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
ember-lifeline:
specifier: ^7.0.0
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
oauth2-pkce:
specifier: ^2.1.3
version: 2.1.3
devDependencies:
'@babel/core':
specifier: ^7.28.5
@@ -4150,6 +4153,9 @@ packages:
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
oauth2-pkce@2.1.3:
resolution: {integrity: sha512-DcMiG5XUKNabf0qV1GOEPo+zwIdYsO2K1FMhiP8XnSCJbujZLOnSC5BCYiCtD0le+aue2fK6qDDvGjcQNF53jQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -10261,6 +10267,8 @@ snapshots:
nwsapi@2.2.23: {}
oauth2-pkce@2.1.3: {}
object-assign@4.1.1: {}
object-hash@1.3.1: {}

View File

@@ -0,0 +1,102 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import UserMenu from 'marco/components/user-menu';
class MockOsmAuthService extends Service {
@tracked isConnected = false;
@tracked userDisplayName = null;
loginCalled = false;
logoutCalled = false;
login() {
this.loginCalled = true;
}
logout() {
this.logoutCalled = true;
}
}
class MockStorageService extends Service {
@tracked connected = false;
@tracked userAddress = null;
}
module('Integration | Component | user-menu', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:osmAuth', MockOsmAuthService);
this.owner.register('service:storage', MockStorageService);
this.osmAuth = this.owner.lookup('service:osmAuth');
});
test('it renders disconnected OSM state correctly', async function (assert) {
this.osmAuth.isConnected = false;
this.storageArg = {
connected: false,
userAddress: null,
};
this.onClose = () => {};
await render(
<template>
<UserMenu @storage={{this.storageArg}} @onClose={{this.onClose}} />
</template>
);
assert
.dom('.account-list .account-item:nth-child(2) .account-info')
.includesText('OpenStreetMap');
assert
.dom('.account-list .account-item:nth-child(2) button')
.hasText('Connect');
assert
.dom('.account-list .account-item:nth-child(2) .account-status')
.hasText('Not connected');
await click('.account-list .account-item:nth-child(2) button');
assert.true(
this.osmAuth.loginCalled,
'osmAuth.login() was called when Connect is clicked'
);
});
test('it renders connected OSM state correctly', async function (assert) {
this.osmAuth.isConnected = true;
this.osmAuth.userDisplayName = 'TestMapper';
this.storageArg = {
connected: false,
userAddress: null,
};
this.onClose = () => {};
await render(
<template>
<UserMenu @storage={{this.storageArg}} @onClose={{this.onClose}} />
</template>
);
assert
.dom('.account-list .account-item:nth-child(2) .account-info')
.includesText('OpenStreetMap');
assert
.dom('.account-list .account-item:nth-child(2) button')
.hasText('Disconnect');
assert
.dom('.account-list .account-item:nth-child(2) .account-status')
.hasText('TestMapper');
await click('.account-list .account-item:nth-child(2) button');
assert.true(
this.osmAuth.logoutCalled,
'osmAuth.logout() was called when Disconnect is clicked'
);
});
});

View File

@@ -0,0 +1,47 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
class MockOsmAuthService extends Service {
handleCallbackCalled = false;
async handleCallback() {
this.handleCallbackCalled = true;
return Promise.resolve();
}
}
class MockRouterService extends Service {
transitionToArgs = [];
transitionTo(...args) {
this.transitionToArgs.push(args);
}
}
module('Unit | Route | oauth/osm-callback', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:osmAuth', MockOsmAuthService);
this.owner.register('service:router', MockRouterService);
});
test('it handles the callback and transitions to index', async function (assert) {
let route = this.owner.lookup('route:oauth/osm-callback');
let osmAuth = this.owner.lookup('service:osmAuth');
let router = this.owner.lookup('service:router');
await route.model();
assert.true(
osmAuth.handleCallbackCalled,
'handleCallback was called on the osmAuth service'
);
assert.deepEqual(
router.transitionToArgs,
[['index']],
'router transitioned back to index route'
);
});
});

View File

@@ -0,0 +1,156 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
module('Unit | Service | osm-auth', function (hooks) {
setupTest(hooks);
let originalLocalStorage;
let originalFetch;
hooks.beforeEach(function () {
// Stub localStorage
let mockStorage = {};
originalLocalStorage = window.localStorage;
Object.defineProperty(window, 'localStorage', {
value: {
getItem: (key) => mockStorage[key] || null,
setItem: (key, value) => {
mockStorage[key] = value.toString();
},
removeItem: (key) => {
delete mockStorage[key];
},
clear: () => {
mockStorage = {};
},
},
writable: true,
});
// Stub fetch
originalFetch = window.fetch;
window.fetch = async () => {
return {
ok: true,
json: async () => ({
user: { display_name: 'MockedUser' },
}),
};
};
});
hooks.afterEach(function () {
window.localStorage = originalLocalStorage;
window.fetch = originalFetch;
});
test('it restores session correctly when logged in', async function (assert) {
let service = this.owner.factoryFor('service:osm-auth').create();
// Stub the underlying oauthClient before it runs restoreSession (actually restoreSession runs in constructor, so we need to intercept it)
// Because restoreSession runs in the constructor, we might need to overwrite it after, but it's async.
// Let's just create it, let the original restoreSession fail or do nothing, and then we stub and re-call it.
service.oauthClient.isAuthorized = async () => true;
window.localStorage.setItem('marco:osm_user_display_name', 'CachedName');
await service.restoreSession();
assert.true(service.isConnected, 'Service becomes connected');
assert.strictEqual(
service.userDisplayName,
'CachedName',
'Restores name from localStorage'
);
});
test('it fetches user info when logged in but no cached name', async function (assert) {
let service = this.owner.factoryFor('service:osm-auth').create();
service.oauthClient.isAuthorized = async () => true;
service.oauthClient.getTokens = async () => ({ accessToken: 'fake-token' });
// Ensure localStorage is empty for this key
window.localStorage.removeItem('marco:osm_user_display_name');
await service.restoreSession();
assert.true(service.isConnected, 'Service becomes connected');
assert.strictEqual(
service.userDisplayName,
'MockedUser',
'Fetched name from API'
);
assert.strictEqual(
window.localStorage.getItem('marco:osm_user_display_name'),
'MockedUser',
'Saved name to localStorage'
);
});
test('it handles login()', async function (assert) {
let service = this.owner.lookup('service:osm-auth');
let requestCalled = false;
service.oauthClient.requestAuthorizationCode = async () => {
requestCalled = true;
};
await service.login();
assert.true(
requestCalled,
'Delegates to oauthClient.requestAuthorizationCode()'
);
});
test('it handles logout()', async function (assert) {
let service = this.owner.lookup('service:osm-auth');
service.isConnected = true;
service.userDisplayName = 'ToBeCleared';
window.localStorage.setItem('marco:osm_user_display_name', 'ToBeCleared');
let resetCalled = false;
service.oauthClient.reset = async () => {
resetCalled = true;
};
await service.logout();
assert.true(resetCalled, 'Delegates to oauthClient.reset()');
assert.false(service.isConnected, 'isConnected is reset');
assert.strictEqual(
service.userDisplayName,
null,
'userDisplayName is reset'
);
assert.strictEqual(
window.localStorage.getItem('marco:osm_user_display_name'),
null,
'localStorage is cleared'
);
});
test('it handles the callback flow', async function (assert) {
let service = this.owner.lookup('service:osm-auth');
let receiveCodeCalled = false;
let getTokensCalled = false;
service.oauthClient.receiveCode = async () => {
receiveCodeCalled = true;
};
service.oauthClient.getTokens = async () => {
getTokensCalled = true;
return { accessToken: 'fake-token' };
};
await service.handleCallback();
assert.true(receiveCodeCalled, 'oauthClient.receiveCode was called');
assert.true(getTokensCalled, 'oauthClient.getTokens was called');
assert.true(service.isConnected, 'Service is connected after callback');
assert.strictEqual(
service.userDisplayName,
'MockedUser',
'User info fetched and set during callback'
);
});
});

View File

@@ -3,9 +3,9 @@ import { extensions, ember } from '@embroider/vite';
import { babel } from '@rollup/plugin-babel';
export default defineConfig({
// server: {
// host: '0.0.0.0',
// },
server: {
host: '127.0.0.1',
},
plugins: [
ember(),
// extra plugins here