Compare commits

...

6 Commits

Author SHA1 Message Date
9d06898b15 1.18.1
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 44s
2026-04-01 19:26:05 +04:00
6df43edbf9 Fix OSM client ID missing in release build 2026-04-01 19:25:16 +04:00
e2fcdce154 1.18.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 46s
2026-04-01 19:14:03 +04:00
829dff9839 Merge pull request 'Connect OSM account' (#40) from feature/osm_auth into master
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 47s
Reviewed-on: #40
2026-04-01 15:12:46 +00:00
e7dfed204e 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
2026-04-01 18:46:19 +04:00
2dfd411837 Merge pull request 'Improve user menu layout, icons' (#39) from feature/user_menu into master
Some checks failed
CI / Lint (push) Successful in 29s
CI / Test (push) Failing after 44s
Reviewed-on: #39
2026-04-01 13:33:07 +00:00
19 changed files with 492 additions and 15 deletions

View File

@@ -1,8 +1,12 @@
# This file is committed to git and should not contain any secrets. # 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 # 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. # SEE: https://vite.dev/guide/env-and-mode.html#env-files for more information.
# Default NODE_ENV with vite build --mode=test is production # Default NODE_ENV with vite build --mode=test is production
NODE_ENV=development NODE_ENV=development
# OpenStreetMap OAuth
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org

3
.env.production Normal file
View File

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

View File

@@ -10,4 +10,7 @@ Router.map(function () {
this.route('place', { path: '/place/:place_id' }); this.route('place', { path: '/place/:place_id' });
this.route('place.new', { path: '/place/new' }); this.route('place.new', { path: '/place/new' });
this.route('search'); 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 { .account-status {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: bold;
color: #898989; color: #898989;
margin-top: 0.35rem; margin-top: 0.35rem;
margin-left: calc(18px + 0.75rem); margin-left: calc(18px + 0.75rem);

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.17.2", "version": "1.18.1",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
@@ -104,6 +104,7 @@
"dependencies": { "dependencies": {
"@waysidemapping/pinhead": "^15.20.0", "@waysidemapping/pinhead": "^15.20.0",
"ember-concurrency": "^5.2.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: ember-lifeline:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6)) 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: devDependencies:
'@babel/core': '@babel/core':
specifier: ^7.28.5 specifier: ^7.28.5
@@ -4150,6 +4153,9 @@ packages:
nwsapi@2.2.23: nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
oauth2-pkce@2.1.3:
resolution: {integrity: sha512-DcMiG5XUKNabf0qV1GOEPo+zwIdYsO2K1FMhiP8XnSCJbujZLOnSC5BCYiCtD0le+aue2fK6qDDvGjcQNF53jQ==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -10261,6 +10267,8 @@ snapshots:
nwsapi@2.2.23: {} nwsapi@2.2.23: {}
oauth2-pkce@2.1.3: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-hash@1.3.1: {} object-hash@1.3.1: {}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-B8Ckz4Ru.js"></script> <script type="module" crossorigin src="/assets/main-18-jE9H3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-OLSOzTKA.css"> <link rel="stylesheet" crossorigin href="/assets/main-uF6fmHZ4.css">
</head> </head>
<body> <body>
</body> </body>

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'; import { babel } from '@rollup/plugin-babel';
export default defineConfig({ export default defineConfig({
// server: { server: {
// host: '0.0.0.0', host: '127.0.0.1',
// }, },
plugins: [ plugins: [
ember(), ember(),
// extra plugins here // extra plugins here