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
33 changes: 33 additions & 0 deletions apps/web/app/cli-auth/CopyToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import { useState } from 'react';

export default function CopyToken({ token }: { token: string }) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* clipboard may be unavailable; user can still select the text */
}
};

return (
<div className="flex flex-col gap-3">
<pre className="bg-gray-900 text-green-300 text-xs rounded-xl p-4 overflow-x-auto whitespace-pre-wrap break-all select-all">
{token}
</pre>
<button
onClick={handleCopy}
className={`self-start inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
copied ? 'bg-green-500 text-white' : 'bg-gray-900 hover:bg-gray-700 text-white'
}`}
>
{copied ? 'Copied ✓' : 'Copy code'}
</button>
</div>
);
}
49 changes: 49 additions & 0 deletions apps/web/app/cli-auth/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export const dynamic = 'force-dynamic';

import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { COOKIE, parseSession } from '@/lib/auth';
import CopyToken from './CopyToken';

export const metadata: Metadata = {
title: 'CLI Login',
robots: { index: false, follow: false },
};

export default async function CliAuthPage() {
const jar = await cookies();
const token = jar.get(COOKIE)?.value ?? null;
const did = token ? await parseSession(token) : null;

return (
<div className="max-w-lg mx-auto py-12">
<h1 className="text-3xl font-black text-gray-900 mb-2">Connect the CLI</h1>

{did && token ? (
<>
<p className="text-gray-500 mb-6 text-sm">
You&apos;re signed in as <span className="font-mono text-gray-700">{did.slice(0, 20)}…</span>.
Copy the code below and paste it back into your terminal.
</p>
<CopyToken token={token} />
<p className="text-xs text-gray-400 mt-6">
Treat this code like a password — it grants access to post coupons and bounties as you for
30 days. Run <code className="font-mono">c0upons logout</code> on your machine to forget it.
</p>
</>
) : (
<>
<p className="text-gray-500 mb-6 text-sm">
Sign in with CoinPay, then you&apos;ll get a code to paste into the CLI.
</p>
<a
href="/api/auth/coinpay?returnTo=/cli-auth"
className="inline-flex items-center gap-2 bg-gray-900 hover:bg-gray-700 text-white font-semibold px-6 py-3 rounded-xl transition-colors"
>
Connect with CoinPay
</a>
</>
)}
</div>
);
}
10 changes: 9 additions & 1 deletion apps/web/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'server-only';
import { cookies } from 'next/headers';
import { cookies, headers } from 'next/headers';

const COOKIE = 'cp_session';
const enc = new TextEncoder();
Expand Down Expand Up @@ -45,6 +45,14 @@ export async function parseSession(value: string): Promise<string | null> {

export async function getSessionDid(): Promise<string | null> {
try {
// CLI / API clients authenticate with the same signed session token sent as
// `Authorization: Bearer <token>`. Browser requests use the httpOnly cookie.
const hdrs = await headers();
const auth = hdrs.get('authorization');
if (auth?.startsWith('Bearer ')) {
const did = await parseSession(auth.slice(7).trim());
if (did) return did;
}
const jar = await cookies();
const val = jar.get(COOKIE)?.value;
if (!val) return null;
Expand Down
170 changes: 166 additions & 4 deletions apps/web/public/cli/c0upons
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@

set -euo pipefail

VERSION="1.0.0"
VERSION="1.1.0"
BASE_URL="${C0UPONS_API:-https://c0upons.com/api}"
# Website root (for login + share links). Defaults to BASE_URL without /api.
WEB_URL="${C0UPONS_WEB:-${BASE_URL%/api}}"

CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/c0upons"
TOKEN_FILE="${CONFIG_DIR}/token"

BOLD="\033[1m"
DIM="\033[2m"
Expand All @@ -27,16 +32,20 @@ usage() {
echo " latest Show latest/trending coupons"
echo " stores List all stores"
echo " store <slug> Show coupons for a store"
echo " login Connect your CoinPay account"
echo " logout Forget saved credentials"
echo " submit Submit a coupon (requires login)"
echo " bounty Post a coupon bounty (requires login)"
echo " upgrade Upgrade to the latest version (alias: update)"
echo " remove Uninstall the CLI (alias: uninstall)"
echo " version Print version"
echo " help Show this help"
echo ""
echo -e "${BOLD}EXAMPLES${RESET}"
echo " c0upons search nike"
echo " c0upons latest"
echo " c0upons stores"
echo " c0upons store adidas"
echo " c0upons login"
echo " c0upons submit --store Nike --title '20% off sitewide' --code SAVE20 --percent 20 --url https://nike.com"
echo " c0upons bounty --store Adidas --title 'Need a 30% off code' --reward 1.00"
echo ""
echo -e "${DIM}Override API: export C0UPONS_API=http://localhost:3000/api${RESET}"
echo -e "${DIM}Docs: https://c0upons.com/docs${RESET}"
Expand All @@ -52,6 +61,35 @@ require_jq() {
fi
}

require_auth() {
if [ ! -f "$TOKEN_FILE" ] || [ ! -s "$TOKEN_FILE" ]; then
echo -e "${RED}Not logged in.${RESET} Run: ${BOLD}c0upons login${RESET}"
exit 1
fi
TOKEN=$(cat "$TOKEN_FILE")
}

# api_post <path> <json-payload> — sends an authenticated POST.
# On success, leaves the response body in $API_BODY. On failure, prints the
# error and exits.
api_post() {
local path="$1" payload="$2" resp code
resp=$(curl -sS -w $'\n%{http_code}' -X POST "${BASE_URL}${path}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d "$payload") || { echo -e "${RED}Network error.${RESET} Could not reach ${BASE_URL}."; exit 1; }
code=$(printf '%s' "$resp" | tail -n1)
API_BODY=$(printf '%s' "$resp" | sed '$d')
if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
return 0
fi
local msg
msg=$(printf '%s' "$API_BODY" | jq -r '.error // empty' 2>/dev/null || true)
echo -e "${RED}Error (${code}):${RESET} ${msg:-request failed}"
[ "$code" = "401" ] && echo -e " Run: ${BOLD}c0upons login${RESET}"
exit 1
}

urlencode() {
local raw="$1"
if command -v python3 &>/dev/null; then
Expand Down Expand Up @@ -156,6 +194,126 @@ cmd_store() {
done <<< "$coupons"
}

cmd_login() {
require_jq
echo -e "To connect the CLI, open this URL and sign in with CoinPay:"
echo ""
echo -e " ${BOLD}${WEB_URL}/cli-auth${RESET}"
echo ""
printf "Paste the code here: "
local token
read -r token
token=$(printf '%s' "$token" | tr -d '[:space:]')
if [ -z "$token" ]; then
echo -e "${RED}No code entered.${RESET}"
exit 1
fi
local did
did=$(curl -fsSL -H "Authorization: Bearer ${token}" "${BASE_URL}/auth/me" 2>/dev/null | jq -r '.did // empty') || true
if [ -z "$did" ]; then
echo -e "${RED}That code didn't work.${RESET} Make sure you copied the whole thing."
exit 1
fi
mkdir -p "$CONFIG_DIR"
printf '%s' "$token" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
echo -e "${GREEN}✓${RESET} Logged in as ${BOLD}${did}${RESET}"
}

cmd_logout() {
if [ -f "$TOKEN_FILE" ]; then
rm -f "$TOKEN_FILE"
echo -e "${GREEN}✓${RESET} Logged out."
else
echo "Not logged in."
fi
}

cmd_submit() {
require_jq
require_auth
local title="" store="" code="" url="" website="" description="" expiry="" dtype="" dvalue=""
while [ $# -gt 0 ]; do
case "$1" in
--title) title="${2:-}"; shift 2 ;;
--store) store="${2:-}"; shift 2 ;;
--code) code="${2:-}"; shift 2 ;;
--url) url="${2:-}"; shift 2 ;;
--website) website="${2:-}"; shift 2 ;;
--description|--desc) description="${2:-}"; shift 2 ;;
--expiry) expiry="${2:-}"; shift 2 ;;
--percent) dtype="percent"; dvalue="${2:-}"; shift 2 ;;
--off) dtype="fixed"; dvalue="${2:-}"; shift 2 ;;
*) echo -e "${RED}Unknown option:${RESET} $1"; exit 1 ;;
esac
done
if [ -z "$title" ]; then
echo "Usage: c0upons submit --title <title> [--store <name>] [--code <code>]"
echo " [--percent <n> | --off <n>] [--url <url>]"
echo " [--description <d>] [--expiry YYYY-MM-DD]"
exit 1
fi
local payload
payload=$(jq -n \
--arg title "$title" --arg store "$store" --arg code "$code" --arg url "$url" \
--arg website "$website" --arg description "$description" --arg expiry "$expiry" \
--arg dtype "$dtype" --arg dvalue "$dvalue" \
'{
title: $title,
store_name: (if $store == "" then null else $store end),
code: (if $code == "" then null else $code end),
url: (if $url == "" then null else $url end),
store_website: (if $website == "" then null else $website end),
description: (if $description == "" then null else $description end),
expiry_date: (if $expiry == "" then null else $expiry end),
discount_type: (if $dtype == "" then null else $dtype end),
discount_value: (if $dvalue == "" then null else $dvalue end)
}')
api_post "/coupons" "$payload"
echo -e "${GREEN}✓${RESET} Coupon submitted!"
}

cmd_bounty() {
require_jq
require_auth
local title="" store="" reward="" url="" description=""
while [ $# -gt 0 ]; do
case "$1" in
--title) title="${2:-}"; shift 2 ;;
--store) store="${2:-}"; shift 2 ;;
--reward) reward="${2:-}"; shift 2 ;;
--url) url="${2:-}"; shift 2 ;;
--description|--desc) description="${2:-}"; shift 2 ;;
*) echo -e "${RED}Unknown option:${RESET} $1"; exit 1 ;;
esac
done
if [ -z "$title" ] || [ -z "$store" ] || [ -z "$reward" ]; then
echo "Usage: c0upons bounty --title <title> --store <name> --reward <usd>"
echo " [--url <url>] [--description <d>]"
exit 1
fi
local payload
payload=$(jq -n \
--arg title "$title" --arg store "$store" --arg reward "$reward" \
--arg url "$url" --arg description "$description" \
'{
title: $title,
store_name: $store,
reward_usd: $reward,
url: (if $url == "" then null else $url end),
description: (if $description == "" then null else $description end)
}')
api_post "/bounties" "$payload"
local pubid pay_url
pubid=$(printf '%s' "$API_BODY" | jq -r '.public_id // .id // empty')
pay_url=$(printf '%s' "$API_BODY" | jq -r '.pay_url // empty')
echo -e "${GREEN}✓${RESET} Bounty posted!"
[ -n "$pubid" ] && echo -e " ${DIM}${WEB_URL}/bounties/${pubid}${RESET}"
if [ -n "$pay_url" ]; then
echo -e " Fund it to make it live: ${BOLD}${pay_url}${RESET}"
fi
}

cmd_upgrade() {
local self
self="$(command -v c0upons 2>/dev/null || echo "")"
Expand Down Expand Up @@ -206,6 +364,10 @@ case "${1:-help}" in
latest) cmd_latest ;;
stores) cmd_stores ;;
store) shift; cmd_store "$@" ;;
login) cmd_login ;;
logout) cmd_logout ;;
submit) shift; cmd_submit "$@" ;;
bounty) shift; cmd_bounty "$@" ;;
upgrade|update) cmd_upgrade ;;
remove|uninstall) cmd_remove ;;
version|--version|-v) echo "c0upons v${VERSION}" ;;
Expand Down
Loading