BTHLABS-66: Prepping for public release: Take five

AKA "Using Apple reviewers as QA for your project". Thanks, y'all! :)
This commit is contained in:
2025-11-27 17:51:19 +01:00
parent cca49f2292
commit 55126f4af6
26 changed files with 386 additions and 97 deletions

View File

@@ -1,6 +1,23 @@
# HotPocket by BTHLabs # HotPocket by BTHLabs
This repository contains the _HotPocket_ project. Minimal self-hosted bookmarking app :).
## The what, the why and the ugly
HotPocket is a minimal self-hosted bookmarking app. It combines a Web
application, companion apps, browser extensions to give you a way to quickly
save links for later.
HotPocket came to be to fill in the blank left by Pocket, after Mozilla shut it
down. I looked at the existing alternatives and found them either too
feature-rich, too involved to self-host or otherwise not to my liking. So I
decided to sit down and build something for myself.
With the what and why out of the way, let's talk about the ugly... At its core
HotPocket is a personal project. I built it by myself and for myself. It may
or may not fit your needs. If it does, happy saving!
If you're feeling up for an adventure, continue reading below :).
## Development setup ## Development setup
@@ -128,6 +145,7 @@ that can be used to configure the services.
| `HOTPOCKET_BACKEND_RUN_MIGRATIONS` | `false` or `true` | Set to `true` to run database muigrations when the container starts. | | `HOTPOCKET_BACKEND_RUN_MIGRATIONS` | `false` or `true` | Set to `true` to run database muigrations when the container starts. |
| `HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME` | N/A | Username for the initial account. | | `HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME` | N/A | Username for the initial account. |
| `HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD` | N/A | Password for the initial account. | | `HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD` | N/A | Password for the initial account. |
| `HOTPOCKET_BACKEND_OPERATOR_EMAIL` | N/A | Instance operator's e-mail. Used to display extra language on login page. |
**Env and App settings** **Env and App settings**

View File

@@ -1,6 +1,6 @@
services: services:
backend: backend:
image: "hotpocket/backend:aio-v25.11.19-01" image: "bthlabs/hotpocket:aio-v25.11.19-01"
environment: environment:
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright" HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket" HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket"

View File

@@ -8,7 +8,7 @@ x-backend-environment: &x-backend-environment
services: services:
webapp: webapp:
image: "hotpocket/backend:deployment-v25.11.19-01" image: "bthlabs/hotpocket:deployment-v25.11.19-01"
environment: environment:
<<: *x-backend-environment <<: *x-backend-environment
HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net" HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net"
@@ -21,7 +21,7 @@ services:
restart: "unless-stopped" restart: "unless-stopped"
admin: admin:
image: "hotpocket/backend:deployment-v25.11.19-01" image: "bthlabs/hotpocket:deployment-v25.11.19-01"
environment: environment:
<<: *x-backend-environment <<: *x-backend-environment
HOTPOCKET_BACKEND_APP: "admin" HOTPOCKET_BACKEND_APP: "admin"
@@ -35,7 +35,7 @@ services:
restart: "unless-stopped" restart: "unless-stopped"
celery-worker: celery-worker:
image: "hotpocket/backend:deployment-v25.11.19-01" image: "bthlabs/hotpocket:deployment-v25.11.19-01"
command: command:
- "/srv/venv/bin/celery" - "/srv/venv/bin/celery"
- "-A" - "-A"
@@ -57,7 +57,7 @@ services:
restart: "unless-stopped" restart: "unless-stopped"
celery-beat: celery-beat:
image: "hotpocket/backend:deployment-v25.11.19-01" image: "bthlabs/hotpocket:deployment-v25.11.19-01"
command: command:
- "/srv/venv/bin/celery" - "/srv/venv/bin/celery"
- "-A" - "-A"

View File

@@ -10,6 +10,7 @@
#import "HPAPI.h" #import "HPAPI.h"
#import "HPCredentialsHelper.h" #import "HPCredentialsHelper.h"
#import "HPRPCClient.h" #import "HPRPCClient.h"
#import "NSBundle+HotPocketExtensions.h"
#import "NSURL+HotPocketExtensions.h" #import "NSURL+HotPocketExtensions.h"
@implementation HPAuthParams @implementation HPAuthParams
@@ -77,18 +78,19 @@
return nil; return nil;
} }
NSDictionary *postAuthenticateURLParams = [[[NSBundle mainBundle] infoDictionary] valueForKey:@"HPAuthFlowPostAuthenticateURLParts"]; NSString *expectedScheme = [NSBundle postAuthenticateURLScheme];
if (postAuthenticateURLParams == nil) { NSString *expectedHost = [NSBundle postAuthenticateURLHost];
if (expectedScheme == nil || expectedHost == nil) {
return nil; return nil;
} }
NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
if ([urlComponents.scheme isEqualToString:[postAuthenticateURLParams valueForKey:@"scheme"]] == NO) { if ([urlComponents.scheme isEqualToString:expectedScheme] == NO) {
return nil; return nil;
} }
if ([urlComponents.host isEqualToString:[postAuthenticateURLParams valueForKey:@"host"]] == NO) { if ([urlComponents.host isEqualToString:expectedHost] == NO) {
return nil; return nil;
} }

View File

@@ -12,6 +12,9 @@ NS_ASSUME_NONNULL_BEGIN
@interface NSBundle (HotPocketExtensions) @interface NSBundle (HotPocketExtensions)
+(NSString *)uname; +(NSString *)uname;
+(NSDictionary *)postAuthenticateURLParams;
+(NSString *)postAuthenticateURLScheme;
+(NSString *)postAuthenticateURLHost;
@end @end

View File

@@ -14,4 +14,23 @@
return [NSString stringWithFormat:@"HotPocket v%@ (%@)", [mainBundle.infoDictionary valueForKey:@"CFBundleShortVersionString"], [mainBundle.infoDictionary valueForKey:@"CFBundleVersion"]]; return [NSString stringWithFormat:@"HotPocket v%@ (%@)", [mainBundle.infoDictionary valueForKey:@"CFBundleShortVersionString"], [mainBundle.infoDictionary valueForKey:@"CFBundleVersion"]];
} }
+(NSDictionary *)postAuthenticateURLParams {
NSDictionary *result = [[[NSBundle mainBundle] infoDictionary] valueForKey:@"HPAuthFlowPostAuthenticateURLParts"];
if (result == nil) {
return [NSDictionary dictionary];
}
return result;
}
+(NSString *)postAuthenticateURLScheme {
NSDictionary *params = [self postAuthenticateURLParams];
return [params valueForKey:@"scheme"];
}
+(NSString *)postAuthenticateURLHost {
NSDictionary *params = [self postAuthenticateURLParams];
return [params valueForKey:@"host"];
}
@end @end

View File

@@ -6,15 +6,19 @@
// //
#import <UIKit/UIKit.h> #import <UIKit/UIKit.h>
#import <AuthenticationServices/AuthenticationServices.h>
NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_BEGIN
@class MultilineLabel; @class MultilineLabel;
@interface AuthorizationProgressViewController : UIViewController @interface AuthorizationProgressViewController : UIViewController<ASWebAuthenticationPresentationContextProviding>
@property IBOutlet UIActivityIndicatorView *progressIndicator; @property IBOutlet UIActivityIndicatorView *progressIndicator;
@property IBOutlet MultilineLabel *progressLabel; @property IBOutlet MultilineLabel *progressLabel;
@property (strong, nullable) NSURL *authorizationURL;
@property (strong, nullable) ASWebAuthenticationSession *webAuthenticationSession;
@property BOOL userCancelledSession;
@end @end

View File

@@ -8,22 +8,36 @@
#import "AuthorizationProgressViewController.h" #import "AuthorizationProgressViewController.h"
#import "AppDelegate.h" #import "AppDelegate.h"
#import "HPAuthFlow.h"
#import "HPCredentialsHelper.h" #import "HPCredentialsHelper.h"
#import "MultilineLabel.h" #import "MultilineLabel.h"
#import "NSBundle+HotPocketExtensions.h"
@interface AuthorizationProgressViewController (AuthorizationProgressViewControllerPrivate) @interface AuthorizationProgressViewController (AuthorizationProgressViewControllerPrivate)
#pragma mark - Private interface #pragma mark - Private interface
-(void)presentAuthorizationError;
@end @end
@implementation AuthorizationProgressViewController @implementation AuthorizationProgressViewController
#pragma mark - View lifecycle #pragma mark - View lifecycle
-(instancetype)initWithCoder:(NSCoder *)coder {
if (self = [super initWithCoder:coder]) {
self.authorizationURL = nil;
self.webAuthenticationSession = nil;
self.userCancelledSession = NO;
}
return self;
}
-(void)viewDidLoad { -(void)viewDidLoad {
[super viewDidLoad]; [super viewDidLoad];
self.progressLabel.text = NSLocalizedString(@"Continue to sign out in your browser...", @"Continue to sign out in your browser..."); self.progressLabel.text = NSLocalizedString(@"Continue to sign in in your browser...", @"Continue to sign in in your browser...");
} }
-(void)viewWillAppear:(BOOL)animated { -(void)viewWillAppear:(BOOL)animated {
@@ -42,26 +56,63 @@
object:appDelegate.authFlow]; object:appDelegate.authFlow];
} }
-(void)viewWillDisappear:(BOOL)animated { -(void)viewDidAppear:(BOOL)animated {
[super viewWillDisappear:animated]; [super viewDidAppear:animated];
[[NSNotificationCenter defaultCenter] removeObserver:self]; AppDelegate *appDelegate = [[UIApplication sharedApplication] delegate];
ASWebAuthenticationSessionCompletionHandler completionHandler = ^(NSURL *url, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (error != nil) {
#ifdef DEBUG
NSLog(@"[AuthorizationViewController.session completionHandler] error=`%@`", error);
#endif
if (error.code == ASWebAuthenticationSessionErrorCodeCanceledLogin) {
self.userCancelledSession = YES;
}
[self presentAuthorizationError];
} else {
HPAuthParams *receivedAuthParams = [appDelegate.authFlow handlePostAuthenticateURL:url];
if (receivedAuthParams != nil) {
[appDelegate.authFlow handleAuthParams:receivedAuthParams];
} else {
[self presentAuthorizationError];
}
}
self.webAuthenticationSession = nil;
});
};
ASWebAuthenticationSessionCallback *callback = [ASWebAuthenticationSessionCallback callbackWithCustomScheme:[NSBundle postAuthenticateURLScheme]];
self.webAuthenticationSession = [[ASWebAuthenticationSession alloc] initWithURL:self.authorizationURL
callback:callback
completionHandler:completionHandler];
self.webAuthenticationSession.presentationContextProvider = self;
#ifdef DEBUG
self.webAuthenticationSession.prefersEphemeralWebBrowserSession = YES;
#endif
if (self.webAuthenticationSession.canStart == NO) {
[self presentAuthorizationError];
return;
}
[self.webAuthenticationSession start];
} }
-(void)viewDidDisappear:(BOOL)animated { -(void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated]; [super viewDidDisappear:animated];
self.webAuthenticationSession = nil;
[self.progressIndicator stopAnimating]; [self.progressIndicator stopAnimating];
[[NSNotificationCenter defaultCenter] removeObserver:self];
} }
#pragma mark - Notification handlers # pragma mark - Private interface
-(void)onAuthFlowDidFinish:(NSNotification *)notification { -(void)presentAuthorizationError {
dispatch_async(dispatch_get_main_queue(), ^{ if (self.userCancelledSession == NO) {
#ifdef DEBUG
NSLog(@"-[AuthorizationViewController onAuthFlowDidFinish:] notification=`%@`", notification);
#endif
HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials];
if (credentials.usable == NO) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Oops!", @"Oops!") UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Oops!", @"Oops!")
message:NSLocalizedString(@"HotPocket couldn't complete this operation.", @"HotPocket couldn't complete this operation.") message:NSLocalizedString(@"HotPocket couldn't complete this operation.", @"HotPocket couldn't complete this operation.")
preferredStyle:UIAlertControllerStyleAlert]; preferredStyle:UIAlertControllerStyleAlert];
@@ -75,6 +126,22 @@
}]]; }]];
[self presentViewController:alert animated:YES completion:nil]; [self presentViewController:alert animated:YES completion:nil];
} else {
[self.navigationController popViewControllerAnimated:YES];
}
}
#pragma mark - Notification handlers
-(void)onAuthFlowDidFinish:(NSNotification *)notification {
dispatch_async(dispatch_get_main_queue(), ^{
#ifdef DEBUG
NSLog(@"-[AuthorizationViewController onAuthFlowDidFinish:] notification=`%@`", notification);
#endif
HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials];
if (credentials.usable == NO) {
[self presentAuthorizationError];
} else { } else {
[self.navigationController popToRootViewControllerAnimated:YES]; [self.navigationController popToRootViewControllerAnimated:YES];
} }
@@ -85,4 +152,10 @@
self.progressLabel.text = NSLocalizedString(@"Processing authorization...", @"Processing authorization..."); self.progressLabel.text = NSLocalizedString(@"Processing authorization...", @"Processing authorization...");
} }
# pragma mark - ASWebAuthenticationPresentationContextProviding implementation
-(ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session {
return self.view.window;
}
@end @end

View File

@@ -73,15 +73,10 @@
return; return;
} }
if ([application canOpenURL:authURL] == YES) {
[application openURL:authURL options:@{} completionHandler:^(BOOL result) {
if (result == YES) {
AuthorizationProgressViewController *authorizationProgressViewController = [self.storyboard instantiateViewControllerWithIdentifier:@"AuthorizationProgressViewController"]; AuthorizationProgressViewController *authorizationProgressViewController = [self.storyboard instantiateViewControllerWithIdentifier:@"AuthorizationProgressViewController"];
authorizationProgressViewController.authorizationURL = authURL;
[self.navigationController pushViewController:authorizationProgressViewController animated:YES]; [self.navigationController pushViewController:authorizationProgressViewController animated:YES];
} }
}];
}
}
#pragma mark - Event handlers #pragma mark - Event handlers

View File

@@ -21,19 +21,6 @@
} }
-(void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls { -(void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
HPAuthParams *receivedAuthParams = nil;
for (NSURL *url in urls) {
receivedAuthParams = [self.authFlow handlePostAuthenticateURL:url];
if (receivedAuthParams != nil) {
break;
}
}
if (receivedAuthParams != nil) {
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
[self.authFlow handleAuthParams:receivedAuthParams];
}
} }
@end @end

View File

@@ -6,13 +6,19 @@
// //
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
#import <AuthenticationServices/AuthenticationServices.h>
NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_BEGIN
@interface AuthorizationProgressViewController : NSViewController @interface AuthorizationProgressViewController : NSViewController<ASWebAuthenticationPresentationContextProviding>
@property IBOutlet NSProgressIndicator *progressIndicator; @property IBOutlet NSProgressIndicator *progressIndicator;
@property NSString *progressLabelTitle; @property NSString *progressLabelTitle;
@property (nullable, strong) NSURL *authorizationURL;
@property (nullable, strong) ASWebAuthenticationSession *webAuthenticationSession;
@property BOOL userCancelledSession;
-(IBAction)doCancel:(id)sender;
@end @end

View File

@@ -9,27 +9,42 @@
#import "AppDelegate.h" #import "AppDelegate.h"
#import "AuthorizationViewController.h" #import "AuthorizationViewController.h"
#import "HPAuthFlow.h"
#import "HPCredentialsHelper.h" #import "HPCredentialsHelper.h"
#import "MainViewController.h" #import "MainViewController.h"
#import "NSBundle+HotPocketExtensions.h"
#import "ReplaceAnimator.h" #import "ReplaceAnimator.h"
@interface AuthorizationProgressViewController (AuthorizationProgressViewControllerPrivate) @interface AuthorizationProgressViewController (AuthorizationProgressViewControllerPrivate)
#pragma mark - Private interface #pragma mark - Private interface
-(void)presentAuthorizationError;
@end @end
@implementation AuthorizationProgressViewController @implementation AuthorizationProgressViewController
#pragma mark - View lifecycle #pragma mark - View lifecycle
-(instancetype)initWithCoder:(NSCoder *)coder {
if (self = [super initWithCoder:coder]) {
self.authorizationURL = nil;
self.webAuthenticationSession = nil;
self.userCancelledSession = NO;
}
return self;
}
-(void)viewDidLoad { -(void)viewDidLoad {
[super viewDidLoad]; [super viewDidLoad];
self.progressLabelTitle = NSLocalizedString(@"Continue to sign out in your browser...", @"Continue to sign out in your browser..."); self.progressLabelTitle = NSLocalizedString(@"Continue to sign in in your browser...", @"Continue to sign in in your browser...");
} }
-(void)viewWillAppear { -(void)viewWillAppear {
AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate]; AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
[[NSNotificationCenter defaultCenter] addObserver:self [[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onAuthFlowDidFinish:) selector:@selector(onAuthFlowDidFinish:)
name:@"AuthFlowDidFinish" name:@"AuthFlowDidFinish"
@@ -43,12 +58,84 @@
[self.progressIndicator startAnimation:self]; [self.progressIndicator startAnimation:self];
} }
-(void)viewDidAppear {
[super viewDidAppear];
AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
ASWebAuthenticationSessionCompletionHandler completionHandler = ^(NSURL *url, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (error != nil) {
#ifdef DEBUG
NSLog(@"[AuthorizationViewController.session completionHandler] error=`%@`", error);
#endif
if (error.code == ASWebAuthenticationSessionErrorCodeCanceledLogin) {
self.userCancelledSession = YES;
}
[self presentAuthorizationError];
} else {
HPAuthParams *receivedAuthParams = [appDelegate.authFlow handlePostAuthenticateURL:url];
if (receivedAuthParams != nil) {
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
[appDelegate.authFlow handleAuthParams:receivedAuthParams];
} else {
[self presentAuthorizationError];
}
}
self.webAuthenticationSession = nil;
});
};
ASWebAuthenticationSessionCallback *callback = [ASWebAuthenticationSessionCallback callbackWithCustomScheme:[NSBundle postAuthenticateURLScheme]];
self.webAuthenticationSession = [[ASWebAuthenticationSession alloc] initWithURL:self.authorizationURL
callback:callback
completionHandler:completionHandler];
self.webAuthenticationSession.presentationContextProvider = self;
#ifdef DEBUG
self.webAuthenticationSession.prefersEphemeralWebBrowserSession = YES;
#endif
if (self.webAuthenticationSession.canStart == NO) {
[self presentAuthorizationError];
return;
}
[self.webAuthenticationSession start];
}
-(void)viewDidDisappear { -(void)viewDidDisappear {
[super viewDidDisappear];
self.webAuthenticationSession = nil;
[self.progressIndicator stopAnimation:self]; [self.progressIndicator stopAnimation:self];
[[NSNotificationCenter defaultCenter] removeObserver:self]; [[NSNotificationCenter defaultCenter] removeObserver:self];
} }
#pragma mark - Actions
-(IBAction)doCancel:(id)sender {
[self.webAuthenticationSession cancel];
}
#pragma mark - Private interface
-(void)presentAuthorizationError {
if (self.userCancelledSession == NO) {
NSAlert *alert = [[NSAlert alloc] init];
alert.alertStyle = NSAlertStyleCritical;
alert.messageText = NSLocalizedString(@"Oops!", @"Oops!");
alert.informativeText = NSLocalizedString(@"HotPocket couldn't complete this operation.", @"HotPocket couldn't complete this operation.");
[alert beginSheetModalForWindow:self.view.window completionHandler:^(NSModalResponse response) {
AuthorizationViewController *authorizationViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationViewController"];
[self presentViewController:authorizationViewController animator:[[ReplaceAnimator alloc] init]];
}];
} else {
AuthorizationViewController *authorizationViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationViewController"];
[self presentViewController:authorizationViewController animator:[[ReplaceAnimator alloc] init]];
}
}
#pragma mark - Notification handlers #pragma mark - Notification handlers
-(void)onAuthFlowDidFinish:(NSNotification *)notification { -(void)onAuthFlowDidFinish:(NSNotification *)notification {
@@ -59,14 +146,7 @@
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; [[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
if (credentials.usable == NO) { if (credentials.usable == NO) {
NSAlert *alert = [[NSAlert alloc] init]; [self presentAuthorizationError];
alert.alertStyle = NSAlertStyleCritical;
alert.messageText = NSLocalizedString(@"Oops!", @"Oops!");
alert.informativeText = NSLocalizedString(@"HotPocket couldn't complete this operation.", @"HotPocket couldn't complete this operation.");
[alert beginSheetModalForWindow:self.view.window completionHandler:^(NSModalResponse response) {
AuthorizationViewController *authorizationViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationViewController"];
[self presentViewController:authorizationViewController animator:[[ReplaceAnimator alloc] init]];
}];
} else { } else {
MainViewController *mainViewController = [self.storyboard instantiateControllerWithIdentifier:@"MainViewController"]; MainViewController *mainViewController = [self.storyboard instantiateControllerWithIdentifier:@"MainViewController"];
[self presentViewController:mainViewController animator:[[ReplaceAnimator alloc] init]]; [self presentViewController:mainViewController animator:[[ReplaceAnimator alloc] init]];
@@ -78,4 +158,10 @@
self.progressLabelTitle = NSLocalizedString(@"Processing authorization...", @"Processing authorization..."); self.progressLabelTitle = NSLocalizedString(@"Processing authorization...", @"Processing authorization...");
} }
# pragma mark - ASWebAuthenticationPresentationContextProviding implementation
-(ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session {
return self.view.window;
}
@end @end

View File

@@ -31,31 +31,24 @@
#pragma mark - Actions #pragma mark - Actions
-(IBAction)doStartAuthorizationFlow:(id)sender { -(IBAction)doStartAuthorizationFlow:(id)sender {
NSAlert *alert = [[NSAlert alloc] init]; AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
alert.alertStyle = NSAlertStyleInformational; appDelegate.authFlow.baseURL = [NSURL URLWithString:self.baseURL];
alert.messageText = NSLocalizedString(@"Continue in the browser", @"Continue in the browser");
alert.informativeText = NSLocalizedString(@"HotPocket will now open the instance in your browser and you can continue to sign in.", @"HotPocket will now open the instance in your browser and you can continue to sign in.");
[alert addButtonWithTitle:NSLocalizedString(@"Let's go!", @"Let's go!")]; NSURL *authURL = [appDelegate.authFlow start];
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", @"Cancel")];
[alert beginSheetModalForWindow:self.view.window completionHandler:^(NSModalResponse response) {
if (response == NSAlertFirstButtonReturn) {
AppDelegate *appDeleate = [[NSApplication sharedApplication] delegate];
appDeleate.authFlow.baseURL = [NSURL URLWithString:self.baseURL];
NSURL *authURL = [appDeleate.authFlow start];
if (authURL == nil) { if (authURL == nil) {
NSBeep(); NSBeep();
return; return;
} }
AuthorizationProgressViewController *authProgressViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationProgressViewController"]; AuthorizationProgressViewController *authProgressViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationProgressViewController"];
authProgressViewController.authorizationURL = authURL;
[self presentViewController:authProgressViewController animator:[[ReplaceAnimator alloc] init]]; [self presentViewController:authProgressViewController animator:[[ReplaceAnimator alloc] init]];
[[NSWorkspace sharedWorkspace] openURL:authURL];
} }
}];
# pragma mark - ASWebAuthenticationPresentationContextProviding implementation
-(ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session {
return self.view.window;
} }
@end @end

View File

@@ -207,6 +207,20 @@
<binding destination="OX4-Oj-1cw" name="value" keyPath="self.progressLabelTitle" id="ydU-jy-p3F"/> <binding destination="OX4-Oj-1cw" name="value" keyPath="self.progressLabelTitle" id="ydU-jy-p3F"/>
</connections> </connections>
</textField> </textField>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Sip-bU-V5i">
<rect key="frame" x="175" y="14" width="76" height="32"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="nYJ-LO-V7O">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
Gw
</string>
</buttonCell>
<connections>
<action selector="doCancel:" target="OX4-Oj-1cw" id="nAC-If-aib"/>
</connections>
</button>
</subviews> </subviews>
</view> </view>
<connections> <connections>

View File

@@ -38,3 +38,5 @@ class PSettings(typing.Protocol):
UI_PAGE_HEAD_INCLUDES: list UI_PAGE_HEAD_INCLUDES: list
UI_PAGE_SCRIPT_INCLUDES: list UI_PAGE_SCRIPT_INCLUDES: list
OPERATOR_EMAIL: str | None

View File

@@ -100,3 +100,9 @@ def page_includes(request: HttpRequest) -> dict:
'script': settings.UI_PAGE_SCRIPT_INCLUDES, 'script': settings.UI_PAGE_SCRIPT_INCLUDES,
}, },
} }
def operator_email(request: HttpRequest) -> dict:
return {
'OPERATOR_EMAIL': settings.OPERATOR_EMAIL,
}

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
import pydantic
class SavesCreateOut(pydantic.BaseModel):
id: uuid.UUID
target_uuid: uuid.UUID
url: pydantic.AnyHttpUrl
def to_rpc(self) -> dict:
return {
'id': self.id,
'target_uuid': self.target_uuid,
'url': str(self.url),
}

View File

@@ -3,19 +3,28 @@ from __future__ import annotations
from bthlabs_jsonrpc_core import register_method from bthlabs_jsonrpc_core import register_method
from django.http import HttpRequest from django.http import HttpRequest
from django.urls import reverse
from hotpocket_backend.apps.core.rpc import wrap_soa_errors from hotpocket_backend.apps.core.rpc import wrap_soa_errors
from hotpocket_backend.apps.ui.dto.rpc import SavesCreateOut
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
from hotpocket_soa.dto.associations import AssociationOut
@register_method(method='saves.create') @register_method(method='saves.create')
@wrap_soa_errors @wrap_soa_errors
def create(request: HttpRequest, url: str) -> AssociationOut: def create(request: HttpRequest, url: str) -> SavesCreateOut:
association = CreateSaveWorkflow().run_rpc( association = CreateSaveWorkflow().run_rpc(
request=request, request=request,
account=request.user, account=request.user,
url=url, url=url,
) )
return association result = SavesCreateOut.model_validate({
'id': association.pk,
'target_uuid': association.target_uuid,
'url': request.build_absolute_uri(reverse(
'ui.associations.view', args=(association.pk,),
)),
})
return result

View File

@@ -36,6 +36,12 @@
{% blocktranslate %}Log in with {{ HOTPOCKET_OIDC_DISPLAY_NAME }}{% endblocktranslate %} {% blocktranslate %}Log in with {{ HOTPOCKET_OIDC_DISPLAY_NAME }}{% endblocktranslate %}
</a> </a>
{% endif %} {% endif %}
{% if OPERATOR_EMAIL %}
<p class="my-0 mt-2 text-center">
<small>{% blocktranslate %}For support with your account, contact the <a href="mailto:{{ OPERATOR_EMAIL }}">instance operator</a>.{% endblocktranslate %}</small>
</p>
{% endif %}
</div> </div>
</div> </div>
{% include "ui/ui/partials/uname.html" %} {% include "ui/ui/partials/uname.html" %}

View File

@@ -72,6 +72,7 @@ TEMPLATES = [
'hotpocket_backend.apps.ui.context_processors.debug', 'hotpocket_backend.apps.ui.context_processors.debug',
'hotpocket_backend.apps.ui.context_processors.version', 'hotpocket_backend.apps.ui.context_processors.version',
'hotpocket_backend.apps.ui.context_processors.appearance_settings', 'hotpocket_backend.apps.ui.context_processors.appearance_settings',
'hotpocket_backend.apps.ui.context_processors.operator_email',
], ],
}, },
}, },
@@ -295,3 +296,5 @@ SAVES_ASSOCIATION_ADAPTER = os.environ.get(
UPLOADS_PATH = None UPLOADS_PATH = None
SITE_SHORT_TITLE = 'HotPocket' SITE_SHORT_TITLE = 'HotPocket'
OPERATOR_EMAIL = os.environ.get('HOTPOCKET_BACKEND_OPERATOR_EMAIL', None)

View File

@@ -54,8 +54,12 @@ def test_ok(authenticated_client: Client,
call_result = result.json() call_result = result.json()
assert 'error' not in call_result assert 'error' not in call_result
save_pk = uuid.UUID(call_result['result']['target_uuid'])
association_pk = uuid.UUID(call_result['result']['id']) association_pk = uuid.UUID(call_result['result']['id'])
save_pk = uuid.UUID(call_result['result']['target_uuid'])
assert call_result['result']['url'].endswith(reverse(
'ui.associations.view', args=(association_pk,),
))
AssociationsTestingService().assert_created( AssociationsTestingService().assert_created(
pk=association_pk, pk=association_pk,

View File

@@ -30,5 +30,9 @@
"content_popup_content_error_message": { "content_popup_content_error_message": {
"message": "HotPocket couldn't complete this operation.", "message": "HotPocket couldn't complete this operation.",
"description": "Title of the error content popup." "description": "Title of the error content popup."
},
"content_popup_content_view_button_message": {
"message": "View in HotPocket",
"description": "Title of the view link button"
} }
} }

View File

@@ -117,10 +117,10 @@ const doSave = async (accessToken, tab) => {
); );
if (error !== null) { if (error !== null) {
return false; return null;
} }
return true; return result;
}; };
const doCreateAndStoreAccessToken = async (authKey) => { const doCreateAndStoreAccessToken = async (authKey) => {

View File

@@ -26,7 +26,7 @@ class Popup {
element.insertAdjacentHTML('beforeend', content); element.insertAdjacentHTML('beforeend', content);
}; };
setContent = (content) => { setContent = (content, saveUrl) => {
const shadow = this.container.shadowRoot; const shadow = this.container.shadowRoot;
const body = shadow.querySelector('.hotpocket-extension-popup-body'); const body = shadow.querySelector('.hotpocket-extension-popup-body');
@@ -38,6 +38,13 @@ class Popup {
i18nElement.dataset.message, i18nElement.dataset.message,
); );
} }
if (saveUrl) {
const viewLink = shadow.querySelector('.hotpocket-extension-popup-view');
if (viewLink) {
viewLink.href = saveUrl;
}
}
}; };
close = () => { close = () => {
this.clearCloseTimeout(); this.clearCloseTimeout();
@@ -47,7 +54,7 @@ class Popup {
this.container = null; this.container = null;
} }
}; };
show = (content) => { show = (content, saveUrl) => {
this.close(); this.close();
this.container = document.createElement('div'); this.container = document.createElement('div');
@@ -57,7 +64,7 @@ class Popup {
const shadow = this.container.attachShadow({mode: 'open'}); const shadow = this.container.attachShadow({mode: 'open'});
shadow.setHTMLUnsafe(POPUP); shadow.setHTMLUnsafe(POPUP);
this.setContent(content); this.setContent(content, saveUrl);
const closeElements = shadow.querySelectorAll('.hotpocket-extension-popup-close'); const closeElements = shadow.querySelectorAll('.hotpocket-extension-popup-close');
for (const closeElement of closeElements) { for (const closeElement of closeElements) {
@@ -66,8 +73,8 @@ class Popup {
document.body.appendChild(this.container); document.body.appendChild(this.container);
}; };
update = (content) => { update = (content, saveUrl) => {
this.setContent(content); this.setContent(content, saveUrl);
}; };
onCloseClick = (event) => { onCloseClick = (event) => {
this.close(); this.close();
@@ -86,16 +93,16 @@ const doHandleBrowserActionClickedMessage = (message) => {
}; };
const doHandleSaveMessage = (message) => { const doHandleSaveMessage = (message) => {
let content = POPUP_CONTENT_ERROR; let content = POPUP_CONTENT_SUCCESS;
if (message.result === true) { if (message.result === null) {
content = POPUP_CONTENT_SUCCESS; content = POPUP_CONTENT_ERROR;
} }
if (currentPopup === null) { if (currentPopup === null) {
currentPopup = new Popup(); currentPopup = new Popup();
currentPopup.show(content); currentPopup.show(content);
} else { } else {
currentPopup.update(content); currentPopup.update(content, (message.result || {}).url);
} }
}; };

View File

@@ -94,6 +94,26 @@
opacity: 0.83; opacity: 0.83;
transform: rotate(60deg); transform: rotate(60deg);
} }
.hotpocket-extension-popup-view {
background-color: #1CBAED;
border: 1px solid #1CBAED;
border-radius: 0.25rem;
color: white;
display: inline-block;
font-size: 0.875rem;
line-height: 1.25;
margin-top: 0.25rem !important;
padding: 0.25rem 0.5rem;
text-decoration: none;
}
.hotpocket-extension-popup-view:hover {
background-color: #189ec9;
border-color: #1695be;
}
.hotpocket-extension-popup-view:active {
background-color: #1695be;
border-color: #158cb2;
}
@keyframes hotpocket-extension-popup-loader-animation { @keyframes hotpocket-extension-popup-loader-animation {
100% {transform: rotate(1turn)} 100% {transform: rotate(1turn)}
} }
@@ -104,6 +124,6 @@
<strong>HotPocket by BTHLabs</strong> <strong>HotPocket by BTHLabs</strong>
<a class="hotpocket-extension-popup-close">&times;</a> <a class="hotpocket-extension-popup-close">&times;</a>
</div> </div>
<div class="hotpocket-extension-popup-body"> <div class="hotpocket-extension-popup-body hotpocket-extension-popup-message-success">
</div> </div>
</div> </div>

View File

@@ -2,4 +2,13 @@
<strong data-message="content_popup_content_success_title"></strong> <strong data-message="content_popup_content_success_title"></strong>
<br> <br>
<span data-message="content_popup_content_success_message"></span> <span data-message="content_popup_content_success_message"></span>
<br>
<a
class="hotpocket-extension-popup-view"
data-message="content_popup_content_view_button_message"
href="#"
rel="noopener noreferrer"
target="_blank"
>
</a>
</p> </p>