Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation } from 'react-router';
import { Link, type Location, type To, useLocation } from 'react-router';
import { useFeatureFlagOrDefault } from '~/features/shared/feature-flags';
import { authQueryOptions } from '~/features/user/auth';
import { cn } from '~/lib/utils';

Expand All @@ -8,27 +9,43 @@ type JoinBetaButtonProps = {
className?: string;
};

function getCallToAction(
isLoggedIn: boolean,
signupEnabled: boolean,
location: Location,
): { to: To; label: string } {
if (isLoggedIn) {
return { to: '/', label: 'Go to Wallet' };
}
if (signupEnabled) {
return { to: { ...location, pathname: '/signup' }, label: 'Get Started' };
}
return { to: { ...location, pathname: '/login' }, label: 'Log in' };
}

export function JoinBetaButton({
size = 'default',
className,
}: JoinBetaButtonProps) {
const location = useLocation();
const { data: authState } = useQuery(authQueryOptions());
const isLoggedIn = authState?.isLoggedIn ?? false;
const signupEnabled = useFeatureFlagOrDefault('WALLET_OPERATIONS');
const { to, label } = getCallToAction(isLoggedIn, signupEnabled, location);

const sizeClasses =
size === 'lg' ? 'h-12 px-7 text-base' : 'h-10 px-5 text-sm';

return (
<Link
to={isLoggedIn ? '/' : { ...location, pathname: '/signup' }}
to={to}
className={cn(
'inline-flex items-center justify-center rounded-md border border-[color:var(--mk-brand)] bg-[color:var(--mk-brand)] font-medium font-mono text-[#04080f] tracking-wide transition-[background-color,color] duration-200 hover:bg-transparent hover:text-[color:var(--mk-brand)] focus-visible:outline-2 focus-visible:outline-[color:var(--mk-brand)] focus-visible:outline-offset-2',
sizeClasses,
className,
)}
>
{isLoggedIn ? 'Go to Wallet' : 'Get Started'}
{label}
</Link>
);
}
16 changes: 10 additions & 6 deletions apps/web-wallet/app/features/homepage/components/marketing-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation } from 'react-router';
import logoUrl from '~/assets/full_logo.png';
import { useFeatureFlagOrDefault } from '~/features/shared/feature-flags';
import { authQueryOptions } from '~/features/user/auth';

const navBtnBase =
Expand All @@ -16,6 +17,7 @@ export function MarketingNav() {
const location = useLocation();
const { data: authState } = useQuery(authQueryOptions());
const isLoggedIn = authState?.isLoggedIn ?? false;
const signupEnabled = useFeatureFlagOrDefault('WALLET_OPERATIONS');

return (
<header className="sticky top-0 z-50 w-full border-[color:var(--mk-border)] border-b bg-[rgba(4,8,15,0.78)] backdrop-blur-[14px] backdrop-saturate-[140%]">
Expand Down Expand Up @@ -50,12 +52,14 @@ export function MarketingNav() {
>
Log in
</Link>
<Link
to={{ ...location, pathname: '/signup' }}
className={`${navBtnBase} ${signupBtn}`}
>
Sign up
</Link>
{signupEnabled && (
<Link
to={{ ...location, pathname: '/signup' }}
className={`${navBtnBase} ${signupBtn}`}
>
Sign up
</Link>
)}
</>
)}
</nav>
Expand Down
19 changes: 13 additions & 6 deletions apps/web-wallet/app/features/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '~/components/ui/card';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { useFeatureFlag } from '~/features/shared/feature-flags';
import { useAuthActions } from '~/features/user/auth';
import { useToast } from '~/hooks/use-toast';
import { buildEmailValidator } from '~/lib/validation';
Expand All @@ -24,6 +25,7 @@ export function LoginForm({ onBack }: Props) {
const { signIn } = useAuthActions();
const { toast } = useToast();
const location = useLocation();
const signupEnabled = useFeatureFlag('WALLET_OPERATIONS');
const {
register,
handleSubmit,
Expand Down Expand Up @@ -112,12 +114,17 @@ export function LoginForm({ onBack }: Props) {
Back
</Button>
</form>
<div className="mt-4 text-center text-sm">
Don&apos;t have a wallet?{' '}
<Link to={{ ...location, pathname: '/signup' }} className="underline">
Sign up
</Link>
</div>
{signupEnabled && (
<div className="mt-4 text-center text-sm">
Don&apos;t have a wallet?{' '}
<Link
to={{ ...location, pathname: '/signup' }}
className="underline"
>
Sign up
</Link>
</div>
)}
</CardContent>
</Card>
);
Expand Down
19 changes: 13 additions & 6 deletions apps/web-wallet/app/features/login/login-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useFeatureFlag } from '~/features/shared/feature-flags';

type Option = 'email' | 'google';
type Props = { onSelect: (option: Option) => Promise<void> };

export function LoginOptions({ onSelect }: Props) {
const [submitting, setSubmitting] = useState<Option | null>(null);
const location = useLocation();
const signupEnabled = useFeatureFlag('WALLET_OPERATIONS');
const handeSelect = async (option: Option) => {
if (submitting) return;

Expand Down Expand Up @@ -47,12 +49,17 @@ export function LoginOptions({ onSelect }: Props) {
Log in with Google
</Button>
</div>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{' '}
<Link to={{ ...location, pathname: '/signup' }} className="underline">
Sign up
</Link>
</div>
{signupEnabled && (
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{' '}
<Link
to={{ ...location, pathname: '/signup' }}
className="underline"
>
Sign up
</Link>
</div>
)}
</CardContent>
</Card>
);
Expand Down
27 changes: 27 additions & 0 deletions apps/web-wallet/app/features/receive/lightning-address-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '~/lib/xchacha20poly1305';
import type { AgicashDb } from '../agicash-db/database';
import { NotFoundError } from '../shared/error';
import type { FeatureFlag } from '../shared/feature-flags';
import { sparkWalletQueryOptions } from '../shared/spark';
import {
ReadUserDefaultAccountRepository,
Expand All @@ -45,6 +46,11 @@ if (!encryptionKey) {
}
const encryptionKeyBytes = hexToBytes(encryptionKey);

const SUNSET_ERROR: LNURLError = {
status: 'ERROR',
reason: 'Agicash is shutting down and no longer accepts payments.',
};

/**
* This data needed to verify the status of lnurl-pay request is encrypted
* to improve user privacy by obfuscating the quote data from the LNURL client
Expand Down Expand Up @@ -109,6 +115,10 @@ export class LightningAddressService {
username: string,
): Promise<LNURLPayParams | LNURLError> {
try {
if (!(await this.isWalletOperationsEnabled())) {
return SUNSET_ERROR;
}

const user = await this.userRepository.getByUsername(username);

if (!user) {
Expand Down Expand Up @@ -156,6 +166,10 @@ export class LightningAddressService {
}

try {
if (!(await this.isWalletOperationsEnabled())) {
return SUNSET_ERROR;
}

const user = await this.userRepository.get(userId);

if (!user) {
Expand Down Expand Up @@ -351,6 +365,19 @@ export class LightningAddressService {
};
}

/**
* The WALLET_OPERATIONS flag gates Lightning Address receives. Its row is missing until the
* migration runs, so a missing key counts as enabled.
*/
private async isWalletOperationsEnabled(): Promise<boolean> {
const { data, error } = await this.db.rpc('evaluate_feature_flags');
if (error) {
throw new Error('Failed to evaluate feature flags', { cause: error });
}
const flags = data as Partial<Record<FeatureFlag, boolean>>;
return flags.WALLET_OPERATIONS ?? true;
}

private buildLnurlpMetadata(username: string): string {
const address = `${username}@${new URL(this.baseUrl).host}`;
return JSON.stringify([
Expand Down
19 changes: 13 additions & 6 deletions apps/web-wallet/app/features/receive/receive-cashu-token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '~/components/page';
import { Button } from '~/components/ui/button';
import { useFeatureFlag } from '~/features/shared/feature-flags';
import { TOKEN_CLAIM_DISABLED_MESSAGE } from '~/features/shared/wallet-operations';
import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link';
import { useToast } from '~/hooks/use-toast';
import { encodeToken } from '~/lib/cashu/token';
Expand Down Expand Up @@ -372,6 +373,7 @@ export function PublicReceiveCashuToken({ token }: { token: Token }) {
const { signUpGuest } = useAuthActions();
const { toast } = useToast();
const guestSignupEnabled = useFeatureFlag('GUEST_SIGNUP');
const walletOperationsEnabled = useFeatureFlag('WALLET_OPERATIONS');
const {
selectableAccounts,
receiveAccount,
Expand All @@ -382,6 +384,12 @@ export function PublicReceiveCashuToken({ token }: { token: Token }) {
useCashuTokenWithClaimableProofs({
token,
});
const canClaim =
!!claimableToken && sourceAccount.canReceive && walletOperationsEnabled;
const cannotClaimMessage = !claimableToken
? cannotClaimReason
: (sourceAccount.cannotReceiveReason ??
'Token from this mint cannot be claimed');

const giftCard = getGiftCardByUrl(sourceAccount.mintUrl);
const mintRequiresTerms =
Expand Down Expand Up @@ -456,7 +464,7 @@ export function PublicReceiveCashuToken({ token }: { token: Token }) {
/>

<div className="absolute top-0 right-0 bottom-0 left-0 mx-auto flex max-w-sm items-center justify-center">
{claimableToken && sourceAccount.canReceive ? (
{canClaim ? (
<div className="w-full max-w-sm px-4">
{giftCard ? (
<div className="flex flex-col items-center gap-3">
Expand Down Expand Up @@ -487,17 +495,16 @@ export function PublicReceiveCashuToken({ token }: { token: Token }) {
) : (
<TokenErrorDisplay
message={
!claimableToken
? cannotClaimReason
: (sourceAccount.cannotReceiveReason ??
'Token from this mint cannot be claimed')
walletOperationsEnabled
? cannotClaimMessage
: TOKEN_CLAIM_DISABLED_MESSAGE
}
/>
)}
</div>
</PageContent>

{claimableToken && sourceAccount.canReceive && (
{canClaim && (
<PageFooter className="pb-14">
<div className="flex flex-col gap-4">
{guestSignupEnabled && (
Expand Down
7 changes: 7 additions & 0 deletions apps/web-wallet/app/features/shared/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export const getErrorMessage = (

export class UniqueConstraintError extends Error {}

export class PermissionDeniedError extends Error {
constructor(message: string) {
super(message);
this.name = 'PermissionDeniedError';
}
}

export class NotFoundError extends Error {
constructor(message: string) {
super(message);
Expand Down
25 changes: 22 additions & 3 deletions apps/web-wallet/app/features/shared/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import * as Sentry from '@sentry/react-router';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import {
queryOptions,
useQuery,
useSuspenseQuery,
} from '@tanstack/react-query';
import { agicashDbClient } from '~/features/agicash-db/database.client';
import { getQueryClient } from '~/features/shared/query-client';

export type FeatureFlag = 'GUEST_SIGNUP' | 'DEBUG_LOGGING_SPARK';
export type FeatureFlag =
| 'GUEST_SIGNUP'
| 'DEBUG_LOGGING_SPARK'
| 'WALLET_OPERATIONS';

type FeatureFlags = Record<FeatureFlag, boolean>;

const FEATURE_FLAG_DEFAULTS: FeatureFlags = {
GUEST_SIGNUP: false,
DEBUG_LOGGING_SPARK: false,
// Gates the core wallet, so a failed fetch must not lock users out.
WALLET_OPERATIONS: true,
};

const MAX_RETRIES = 3;
Expand All @@ -19,7 +28,8 @@ async function fetchFeatureFlags(): Promise<FeatureFlags> {
if (error) {
throw new Error('Failed to fetch feature flags', { cause: error });
}
return data as FeatureFlags;
// A flag row is missing until its migration runs, so defaults fill the gaps.
return { ...FEATURE_FLAG_DEFAULTS, ...(data as Partial<FeatureFlags>) };
}

export const featureFlagsQueryOptions = queryOptions({
Expand Down Expand Up @@ -48,6 +58,15 @@ export function useFeatureFlag(flag: FeatureFlag): boolean {
return data[flag];
}

/**
* Like {@link useFeatureFlag} but never suspends, so it is safe in server-rendered trees.
* Returns the default value until the flags are fetched.
*/
export function useFeatureFlagOrDefault(flag: FeatureFlag): boolean {
const { data } = useQuery(featureFlagsQueryOptions);
return data?.[flag] ?? FEATURE_FLAG_DEFAULTS[flag];
}

/**
* Reads a feature flag from the query cache.
* Returns the default value if flags haven't been fetched yet.
Expand Down
Loading
Loading